scopedvr-map-editor/tests/test_bauen.gd
slyphax 3f0d0f3f33 Map-Editor: UI aufgeräumt, Auswahl-Sync, Drag&Drop, Bauen (Wände bündig, Geschosshöhe, große Dächer)
- Tabs Auswahl/Prüfen/Test-Start/Einstellungen/Symmetrie weg; Spawn-Zonen, Test-Start, Symmetrie im Map-Tab; Einstellungen-Fenster (Gitter, Höhenlinien, Symmetrie-Modus, UI-Größe, Raster-Snap, Gizmo-Schritt, Karte prüfen)
- Bauen-Tab nur Bauteile; Objekt-Werkzeuge entfernt
- Klick in der Map-Inhalt-Liste wählt in der Szene aus (SELECT_MULTI feuert nur multi_selected)
- Objekt-Kachel per Ziehen in die Szene setzen
- Wände bündig in der Zelle, gleiche Geschosshöhe mit/ohne Decke, Dächer/Giebelwände 4 m und 6 m

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 20:32:32 +01:00

443 lines
24 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
## Editor-Tests (headless): Bauen-Modus (Umbau 25.10. — Bauteile sind normale
## Bibliotheks-Objekte `bau_*`, Gebäude = Gruppe).
## Ausführen: godot --headless --path . --script res://tests/test_bauen.gd
## Exit 0 = alles grün. Läuft OHNE UI (BauKatalog/BuildSnap/BauOps/SvmapIO/
## ActionHistory) — Ghost/Panel prüft der Desktop-Smoke (--bauen-demo).
var failures := 0
var checks := 0
func _init() -> void:
_cleanup()
_run_tests()
_cleanup()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _cleanup() -> void:
for path in ["user://test_bauen.svmap"]:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _run_tests() -> void:
_test_catalog()
_test_registry()
_test_meshes()
_test_openings()
_test_stairs()
_test_snap_cells_and_edges()
_test_snap_levels()
_test_replace_and_stack()
_test_ops_group_undo()
_test_duplicate_building()
_test_save_load()
_test_migration()
# ------------------------------------------------------------------ Tests ---
func _test_catalog() -> void:
print("Katalog")
_check(BauKatalog.KINDS.size() == 18, "18 Bauteile im Katalog")
var in_gruppen := {}
for g in BauKatalog.GRUPPEN:
for oid in (g["oids"] as Array):
in_gruppen[oid] = true
_check(BauKatalog.KINDS.has(oid), "Gruppe verweist auf bekanntes Teil %s" % oid)
_check(in_gruppen.size() == BauKatalog.KINDS.size(), "jedes Teil steht in genau einer Gruppe (%d)" % in_gruppen.size())
for oid in BauKatalog.oids():
_check(str(oid).begins_with("bau_"), "%s: ID mit bau_-Präfix" % oid)
_check(BauKatalog.label(oid).length() > 3, "%s: deutscher Name" % oid)
_check(["zelle", "kante", "ecke", "treppe"].has(BauKatalog.snap_art(oid)), "%s: Snap-Art" % oid)
func _test_registry() -> void:
print("Registry / Dateien")
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://library/objects.json"))
var lib: Dictionary = (parsed as Dictionary).get("objects", {})
for oid in BauKatalog.oids():
_check(lib.has(oid), "%s steht in objects.json" % oid)
var e: Dictionary = lib.get(oid, {})
var soll := BauKatalog.registry_eintrag(oid)
var gleich := int(e.get("poly", -1)) == int(soll["poly"]) and str(e.get("glb", "")) == str(soll["glb"]) \
and str(e.get("name_de", "")) == str(soll["name_de"]) \
and str((e.get("collision", {}) as Dictionary).get("quelle", "")) == str((soll["collision"] as Dictionary)["quelle"])
var sz: Array = (e.get("collision", {}) as Dictionary).get("size", [])
var sz_soll: Array = (soll["collision"] as Dictionary)["size"]
for k in 3:
gleich = gleich and sz.size() == 3 and absf(float(sz[k]) - float(sz_soll[k])) < 0.002
_check(gleich, "%s: Registry = Katalog (sonst tools/gen_bauteile.gd neu laufen lassen)" % oid)
_check(str(e.get("kategorie", "")) == "bauteile", "%s: Kategorie bauteile" % oid)
var col: Dictionary = e.get("collision", {})
_check(str(col.get("shape", "")) == "trimesh", "%s: Trimesh-Kollision" % oid)
var path := "res://library/%s" % str(col.get("quelle", ""))
_check(FileAccess.file_exists(path), "%s: Kollisionsdatei vorhanden" % oid)
var kj: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
var verts: Array = (kj as Dictionary).get("vertices", []) if kj is Dictionary else []
_check(verts.size() == int(e.get("poly", -1)) * 3, "%s: Kollisions-Dreiecke = poly (%d)" % [oid, int(e.get("poly", -1))])
_check(int(e.get("poly", 0)) <= 260, "%s: Quest-Budget (≤ 260 Dreiecke)" % oid)
func _test_meshes() -> void:
print("Meshes / Fußpunkt-Normalisierung")
for oid in BauKatalog.oids():
var m := BauKatalog.mesh(oid)
_check(m != null and m.get_faces().size() >= 36, "%s: Mesh vorhanden" % oid)
var aabb := m.get_aabb()
_check(absf(aabb.position.y) < 0.001, "%s: Unterkante y = 0" % oid)
_check(absf(aabb.position.x + aabb.size.x * 0.5) < 0.001 and absf(aabb.position.z + aabb.size.z * 0.5) < 0.001,
"%s: XZ zentriert" % oid)
var arr := m.surface_get_arrays(0)
var verts: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
var nrms: PackedVector3Array = arr[Mesh.ARRAY_NORMAL]
var falsch := 0
for t in range(0, verts.size() - 2, 3):
if (verts[t + 2] - verts[t]).cross(verts[t + 1] - verts[t]).dot(nrms[t]) < 0.0:
falsch += 1
_check(falsch == 0, "%s: Wicklung passt zur Normale (kein Culling-Fehler)" % oid)
var mat := m.surface_get_material(0) as StandardMaterial3D
_check(mat != null and mat.albedo_color.a > 0.99 and mat.albedo_color != Color.WHITE, "%s: Grundfarbe im Material" % oid)
var w := BauKatalog.groesse("bau_wand")
_check(absf(w.x - 2.0) < 0.01 and absf(w.y - 3.0) < 0.01 and absf(w.z - 0.2) < 0.01, "Wand 2×3×0,2 m")
var f := BauKatalog.groesse("bau_fundament")
_check(absf(f.x - 2.0) < 0.01 and absf(f.y - 0.2) < 0.01 and absf(f.z - 2.0) < 0.01, "Fundament 2×0,2×2 m")
var b := BauKatalog.groesse("bau_bruestung")
_check(absf(b.y - 1.1) < 0.01, "Brüstung 1,1 m hoch")
## Strahl durch das Kollisions-Mesh: true = trifft (Wand), false = frei (Öffnung).
func _trifft(oid: String, origin: Vector3, dir: Vector3) -> bool:
var faces := BauKatalog.mesh(oid).get_faces()
for i in int(faces.size() / 3):
if SelectionMath.ray_tri(origin, dir, faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2]) >= 0.0:
return true
return false
func _test_openings() -> void:
print("Türen / Fenster / Scharte sind echte Löcher")
var quer := Vector3(0.0, 0.0, 1.0)
# Tür: Mitte (x=0) frei von Boden bis 2,2 m, daneben (x=0.8) massiv, über der Tür (y=2.6) massiv.
_check(not _trifft("bau_wand_tuer", Vector3(0.0, 0.5, -1.0), quer), "Tür: Durchgang knapp über dem Boden frei")
_check(not _trifft("bau_wand_tuer", Vector3(0.0, 2.0, -1.0), quer), "Tür: Durchgang auf 2,0 m frei")
_check(_trifft("bau_wand_tuer", Vector3(0.0, 2.6, -1.0), quer), "Tür: über der Tür ist Wand")
_check(_trifft("bau_wand_tuer", Vector3(0.8, 1.0, -1.0), quer), "Tür: neben dem Rahmen ist Wand")
# Fenster: 1,1–2,3 m offen.
_check(not _trifft("bau_wand_fenster", Vector3(0.0, 1.7, -1.0), quer), "Fenster: Öffnung auf 1,7 m frei")
_check(_trifft("bau_wand_fenster", Vector3(0.0, 0.5, -1.0), quer), "Fenster: unter der Brüstung ist Wand")
_check(_trifft("bau_wand_fenster", Vector3(0.0, 2.6, -1.0), quer), "Fenster: darüber ist Wand")
# Schießscharte: Schlitz auf 1,3–1,7 m.
_check(not _trifft("bau_wand_scharte", Vector3(0.0, 1.5, -1.0), quer), "Scharte: Schlitz auf 1,5 m frei")
_check(_trifft("bau_wand_scharte", Vector3(0.0, 1.0, -1.0), quer), "Scharte: darunter Wand")
_check(_trifft("bau_wand", Vector3(0.0, 1.5, -1.0), quer), "Volle Wand ist dicht")
func _test_stairs() -> void:
print("Treppe begehbar")
var faces := BauKatalog.mesh("bau_treppe").get_faces()
var g := BauKatalog.groesse("bau_treppe")
_check(absf(g.x - 4.0) < 0.01 and absf(g.y - 3.0) < 0.01 and absf(g.z - 2.0) < 0.01, "Treppe 4×3×2 m")
# Oberste Höhe unter x entlang der Mittellinie (z=0): steigt monoton, Stufen ≤ 22 cm.
var letzte := 0.0
var max_sprung := 0.0
var steigt := true
for k in range(1, 40):
var x := -2.0 + 4.0 * float(k) / 40.0
var top := -1.0
for i in int(faces.size() / 3):
var t := SelectionMath.ray_tri(Vector3(x, 10.0, 0.0), Vector3.DOWN,
faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2])
if t >= 0.0:
top = maxf(top, 10.0 - t)
if top < letzte - 0.001:
steigt = false
max_sprung = maxf(max_sprung, top - letzte)
letzte = top
_check(steigt, "Lauffläche steigt monoton")
_check(max_sprung <= 0.22, "Stufenhöhe ≤ 22 cm (größter Sprung %.3f)" % max_sprung)
_check(letzte > 2.7, "Oben ≥ 2,7 m erreicht (%.2f)" % letzte)
func _flach(h: float) -> Callable:
return func(_x: float, _z: float) -> float: return h
func _test_snap_cells_and_edges() -> void:
print("Snap: Zelle / Kante / Ecke / Treppe")
var kein: Array = []
var s := BuildSnap.berechne("bau_fundament", Vector3(0.3, 0.0, 0.4), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(1.0, 0.0, 1.0), "Fundament: Zellmitte (1, 1)")
s = BuildSnap.berechne("bau_fundament", Vector3(-0.3, 0.0, -2.9), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(-1.0, 0.0, -3.0), "Fundament: negative Zelle (−1, −3)")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.1), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(1.0, 0.0, 0.1) and float(s["rot"]) == 0.0, "Wand: Kante entlang X, Drehung 0, 10 cm in die Zelle gerückt (bündig)")
s = BuildSnap.berechne("bau_wand", Vector3(0.1, 0.0, 1.0), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(0.1, 0.0, 1.0) and float(s["rot"]) == 90.0, "Wand: Kante entlang Z, Drehung 90, 10 cm in die Zelle gerückt")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.1), 90.0, kein, _flach(0.0))
_check(float(s["rot"]) == 180.0 and s["pos"] == Vector3(1.0, 0.0, 0.1), "R dreht die Wand um (180°), bleibt an ihrer Stelle")
s = BuildSnap.berechne("bau_wand", Vector3(0.1, 0.0, 1.0), 90.0, kein, _flach(0.0))
_check(float(s["rot"]) == 270.0, "R: Z-Kante 90° → 270°")
s = BuildSnap.berechne("bau_pfeiler", Vector3(1.8, 0.0, 2.3), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(2.0, 0.0, 2.0), "Pfeiler: nächste Zellecke")
s = BuildSnap.berechne("bau_treppe", Vector3(3.3, 0.0, 1.2), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(4.0, 0.0, 1.0) and float(s["rot"]) == 0.0, "Treppe: 4×2-Fläche entlang X")
s = BuildSnap.berechne("bau_treppe", Vector3(1.2, 0.0, 3.3), 90.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(1.0, 0.0, 4.0) and float(s["rot"]) == 90.0, "Treppe: gedreht 90° → entlang Z")
# Alle Fälle: Wandmitte = 10 cm neben einer Kantenmitte (Wand liegt in der Zelle)
for k in range(60):
var p := Vector3(-9.0 + 0.31 * k, 0.0, 7.0 - 0.27 * k)
var w := BuildSnap.berechne("bau_wand", p, 0.0, kein, _flach(0.0))
var wp: Vector3 = w["pos"]
var ex := absf(absf(wp.x - roundf(wp.x / 2.0) * 2.0) - 0.1) < 0.001 # X nahe einer geraden Linie
var ez := absf(absf(wp.z - roundf(wp.z / 2.0) * 2.0) - 0.1) < 0.001
var ox := absf(fposmod(wp.x, 2.0) - 1.0) < 0.001 # X auf Zellmitte
var oz := absf(fposmod(wp.z, 2.0) - 1.0) < 0.001
if not ((ex and oz) or (ez and ox)):
_check(false, "Wand-Mitte liegt in der Zelle an der Kante (%s)" % str(wp))
return
# und zwar in der Zelle, in der die Maus war (Außenseite bündig auf der Kante)
if (ez and absf(wp.z - p.z) > 1.01) or (ex and absf(wp.x - p.x) > 1.01):
_check(false, "Wand liegt auf der Maus-Seite (%s zu %s)" % [str(wp), str(p)])
return
_check(true, "60 Zufallsklicks: Wand liegt immer in der Zelle der Maus, bündig an einer Kante")
# Ecke: zwei Wände in einer Zellecke schließen sich ohne Lücke (äußere Ecke voll)
var wa := BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.3), 0.0, kein, _flach(0.0))
var wb := BuildSnap.berechne("bau_wand", Vector3(0.3, 0.0, 1.0), 0.0, kein, _flach(0.0))
var ra := BuildSnap.teil_rect({"oid": "bau_wand", "pos": wa["pos"], "rot": wa["rot"]})
var rb := BuildSnap.teil_rect({"oid": "bau_wand", "pos": wb["pos"], "rot": wb["rot"]})
_check(ra.has_point(Vector2(0.01, 0.01)) and rb.has_point(Vector2(0.01, 0.01)), "Ecke: äußere Ecke (0,0) ist von BEIDEN Wänden gedeckt")
_check(absf(ra.position.x) < 0.001 and absf(ra.position.y) < 0.001 and absf(rb.position.x) < 0.001 and absf(rb.position.y) < 0.001,
"Ecke: beide Wände beginnen bündig an der Außenkante (0,0)")
# Große Teile: Zellzahl bestimmt die Rasterung
s = BuildSnap.berechne("bau_dach_giebel_4", Vector3(3.2, 0.0, 3.2), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(4.0, 0.0, 4.0), "Satteldach 4×4: Mitte auf Zellecke (4, 4)")
s = BuildSnap.berechne("bau_dach_pyramide_6", Vector3(3.2, 0.0, 3.2), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(3.0, 0.0, 3.0), "Pyramidendach 6×6: Mitte einer Zelle (3, 3)")
s = BuildSnap.berechne("bau_giebelwand_4", Vector3(3.0, 0.0, 0.5), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(4.0, 0.0, 0.1), "Giebelwand 4 m: Mitte auf gerader Linie (4), in die Zelle gerückt")
# Gelände: höchste Höhe unter der Grundfläche zählt
s = BuildSnap.berechne("bau_fundament", Vector3(1.0, 5.0, 1.0), 0.0, kein, _flach(5.0))
_check(absf(float(s["ebene"]) - 5.0) < 0.001, "Gelände-Höhe wird die Ebene (5,0)")
func _teil(i: int, oid: String, pos: Vector3, rot: float) -> Dictionary:
return {"i": i, "oid": oid, "pos": pos, "rot": rot}
func _test_snap_levels() -> void:
print("Snap: Ebenen statt Klickhöhe")
var teile: Array = [
_teil(0, "bau_fundament", Vector3(1, 0, 1), 0.0),
_teil(1, "bau_wand", Vector3(1, 0, 0.1), 0.0),
_teil(2, "bau_wand", Vector3(1.9, 0, 1), 90.0),
]
# Klick mitten auf die Seite der Wand (y=1,5): Boden darf NICHT in der Luft landen
var s := BuildSnap.berechne("bau_boden", Vector3(0.9, 1.5, 0.6), 0.0, teile, _flach(0.0))
_check(absf(float(s["ebene"]) - 0.2) < 0.001, "Boden nach Klick auf Wandseite liegt auf dem Fundament (0,2), nicht bei 1,5")
# Klick auf die Oberkante der Wand: nächste Geschoss-Ebene
s = BuildSnap.berechne("bau_boden", Vector3(1.0, 3.0, 0.1), 0.0, teile, _flach(0.0), true, Vector3(0, -0.5, 1))
_check(absf(float(s["ebene"]) - 3.0) < 0.001, "Boden nach Klick auf Wandoberkante = Decke auf 3,0")
_check(s["pos"] == Vector3(1.0, 3.0, 1.0), "…und in der Zelle, in der die Wand steht (1, 1) — die Decke liegt auf der Wand")
s = BuildSnap.berechne("bau_boden", Vector3(1.0, 1.0, 1.0), 0.0, [_teil(9, "bau_pfeiler", Vector3(2, 0, 2), 0.0)], _flach(0.0), true, Vector3(0, -0.5, 1))
_check(s["pos"].z == 1.0, "Treffer auf Nicht-Wand: Zelle der Kameraseite (Pfeiler an der Ecke)")
# Wand auf Wand (Stapeln): genau über der Wand darunter
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 3.0, 0.1), 0.0, teile, _flach(0.0), true)
_check(absf(float(s["ebene"]) - 3.0) < 0.001 and int(s["ersetze"]) == -1, "Wand stapelt sich auf die Oberkante (3,0)")
_check(s["pos"] == Vector3(1.0, 3.0, 0.1), "…und exakt über der Wand darunter (gleiche Lage)")
# Test-Befund 25.10.: obere Wände sitzen gleich hoch, egal ob auf der Nachbarwand
# eine Decke liegt oder nicht (Decke liegt AUF der Wand, Wände zählen ab Deckenunterkante).
var mit_decke: Array = teile.duplicate()
mit_decke.append(_teil(3, "bau_boden", Vector3(1, 3.0, -1), 0.0)) # Decke über der Wand vorn (Zelle −1)
var ohne := BuildSnap.berechne("bau_wand", Vector3(1.0, 3.0, 0.1), 0.0, mit_decke, _flach(0.0), true)
var mit := BuildSnap.berechne("bau_wand", Vector3(1.0, 3.2, -0.1), 0.0, mit_decke, _flach(0.0), true)
_check(absf(float(ohne["ebene"]) - 3.0) < 0.001, "Obergeschoss-Wand ohne Decke: Höhe 3,0 (%.2f)" % float(ohne["ebene"]))
_check(absf(float(mit["ebene"]) - 3.0) < 0.001, "Obergeschoss-Wand AUF der Decke: ebenfalls 3,0 (%.2f)" % float(mit["ebene"]))
# Ein Dach ist kein Träger: nichts darf auf der Dachspitze landen
teile.append(_teil(4, "bau_dach_giebel", Vector3(1, 3.0, 1), 0.0))
s = BuildSnap.berechne("bau_boden", Vector3(1.0, 4.0, 1.0), 0.0, teile, _flach(0.0))
_check(float(s["ebene"]) <= 3.0 + 0.001, "Dachspitze (4,0) ist keine Ebene (Ebene %.2f)" % float(s["ebene"]))
func _test_replace_and_stack() -> void:
print("Ersetzen / doppelt")
var teile: Array = [
_teil(0, "bau_fundament", Vector3(1, 0, 1), 0.0),
_teil(1, "bau_wand", Vector3(1, 0, 0.1), 0.0),
]
var s := BuildSnap.berechne("bau_wand_tuer", Vector3(1.0, 1.5, 0.1), 0.0, teile, _flach(0.0))
_check(bool(s["ok"]) and int(s["ersetze"]) == 1, "Tür in vorhandene Wand → ersetzt Teil 1")
# Klick direkt auf die Wand (Objekt-Treffer) übernimmt ihre Lage, auch von der anderen Seite
s = BuildSnap.berechne("bau_wand_tuer", Vector3(1.0, 1.5, 0.0), 0.0, teile, _flach(0.0), true, Vector3(0, 0, 1))
_check(bool(s["ok"]) and int(s["ersetze"]) == 1, "Tür: Klick auf die Außenseite der Wand ersetzt dieselbe Wand")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 1.5, 0.1), 0.0, teile, _flach(0.0))
_check(not bool(s["ok"]) and str(s["grund"]).contains("schon"), "gleiche Wand nochmal → abgelehnt mit Erklärung")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 1.5, 0.1), 90.0, teile, _flach(0.0))
_check(bool(s["ok"]) and int(s["ersetze"]) == 1, "Wand von der anderen Seite (R) ersetzt = dreht um")
s = BuildSnap.berechne("bau_wand_tuer", Vector3(3.0, 0.0, 0.1), 0.0, teile, _flach(0.0))
_check(bool(s["ok"]) and int(s["ersetze"]) == -1, "Nachbarkante bleibt frei (kein Ersetzen)")
func _neue_doc() -> Dictionary:
return SvmapDocument.create_new("Bautest", 256.0, "mittag")
func _test_ops_group_undo() -> void:
print("BauOps: Gruppe, Undo, Ersetzen")
var doc := _neue_doc()
var hist := ActionHistory.new()
hist.begin(doc)
var snap := BuildSnap.berechne("bau_fundament", Vector3(1, 0, 1), 0.0, [], _flach(0.0))
var before := hist.snapshot(["objects", "group_names"])
if not before.has("group_names"):
before["group_names"] = null
var gid := BauOps.gruppe_neu(doc)
_check(GroupState.group_name(doc, gid) == "Gebäude 1", "Gebäude-Gruppe heißt „Gebäude 1“")
var r := BauOps.setze_teil(doc, "bau_fundament", snap, gid, SymmetryCore.Mode.OFF)
hist.commit("Fundament bauen", before, hist.snapshot(["objects", "group_names"]))
var objs: Array = doc["objects"]
var n0 := objs.size()
_check(n0 >= 1 and int(r["index"]) == n0 - 1, "Teil als Objekt angehängt")
_check(GroupOps.group_id(objs[n0 - 1]) == gid, "Teil gehört zum Gebäude (Gruppe %d)" % gid)
_check(BauOps.teile_zahl(doc) == 1, "teile_zahl = 1")
# Wand setzen, dann Tür ersetzt dieselbe Stelle (Index bleibt)
var teile := BuildSnap.sammle_teile(doc)
var ws := BuildSnap.berechne("bau_wand", Vector3(1, 0.2, 0.15), 0.0, teile, _flach(0.0))
var before2 := hist.snapshot(["objects", "group_names"])
var wr := BauOps.setze_teil(doc, "bau_wand", ws, gid, SymmetryCore.Mode.OFF)
hist.commit("Wand bauen", before2, hist.snapshot(["objects", "group_names"]))
var widx := int(wr["index"])
teile = BuildSnap.sammle_teile(doc)
var ts := BuildSnap.berechne("bau_wand_tuer", Vector3(1, 1.5, 0.15), 0.0, teile, _flach(0.0))
_check(int(ts["ersetze"]) == widx, "Tür-Klick findet die Wand")
var before3 := hist.snapshot(["objects", "group_names"])
var tr := BauOps.setze_teil(doc, "bau_wand_tuer", ts, gid, SymmetryCore.Mode.OFF)
hist.commit("Tür einsetzen", before3, hist.snapshot(["objects", "group_names"]))
_check(bool(tr["ersetzt"]) and (doc["objects"] as Array).size() == n0 + 1, "Ersetzen fügt kein zusätzliches Objekt hinzu")
_check(str((doc["objects"] as Array)[widx]["id"]) == "bau_wand_tuer", "Objekt ist jetzt die Tür-Wand")
_check(GroupOps.group_id((doc["objects"] as Array)[widx]) == gid, "Gruppe bleibt beim Ersetzen")
hist.undo()
_check(str((doc["objects"] as Array)[widx]["id"]) == "bau_wand", "Undo: wieder normale Wand")
hist.undo()
hist.undo()
_check((doc["objects"] as Array).is_empty(), "Undo ×3: alles weg")
_check(not doc.has("group_names") or (doc["group_names"] as Dictionary).is_empty(), "Undo entfernt auch den Gebäude-Namen")
hist.redo()
hist.redo()
hist.redo()
_check(BauOps.teile_zahl(doc) == 2, "Redo ×3 stellt Fundament + Tür-Wand wieder her")
# Gebäude-Info
var geb := BauOps.gebaeude_gruppen(doc)
_check(geb.size() == 1 and (geb[0]["indizes"] as Array).size() == 2, "gebaeude_gruppen: 1 Gebäude mit 2 Teilen")
_check(BauOps.gebaeude_von(doc, 0).size() == 2, "gebaeude_von liefert alle Mitglieder")
# Haus komplett
var d2 := _neue_doc()
var gid2 := BauOps.haus_demo(d2, null, Vector3(4.0, 0.0, 4.0))
_check(BauOps.teile_zahl(d2) >= 13, "Demo-Haus: %d Teile über die echte Snap-Logik" % BauOps.teile_zahl(d2))
_check(BauOps.gebaeude_von(d2, 0).size() == BauOps.teile_zahl(d2), "alle Teile des Hauses in einer Gruppe (%d)" % gid2)
var doppelt := {}
var hat_doppelte := false
for t in BuildSnap.sammle_teile(d2):
var key := "%s|%s|%s" % [str((t["pos"] as Vector3).snapped(Vector3(0.01, 0.01, 0.01))), str(roundf(float(t["rot"]))), BauKatalog.snap_art(str(t["oid"]))]
if doppelt.has(key):
hat_doppelte = true
doppelt[key] = true
_check(not hat_doppelte, "Haus hat keine doppelt gesetzten Teile")
func _test_duplicate_building() -> void:
print("Gebäude kopieren / löschen")
var doc := _neue_doc()
var gid := BauOps.haus_demo(doc, null, Vector3(4.0, 0.0, 4.0))
var anzahl := (doc["objects"] as Array).size()
var alle := BauOps.gebaeude_von(doc, 0)
var neue := ObjectOps.duplicate_objects(doc, alle, false, 8.0)
_check(neue.size() == anzahl, "alle %d Teile kopiert" % anzahl)
var gid_kopie := GroupOps.group_id((doc["objects"] as Array)[int(neue[0])])
_check(gid_kopie != gid and gid_kopie != GroupOps.UNGROUPED, "Kopie bekommt eine EIGENE Gruppe (%d ≠ %d)" % [gid_kopie, gid])
_check(GroupOps.members_of(doc, gid).size() == anzahl and GroupOps.members_of(doc, gid_kopie).size() == anzahl,
"Original und Kopie haben je %d Teile" % anzahl)
_check(GroupState.display_name(doc, gid_kopie).contains("Kopie"), "Kopie heißt „… (Kopie)“")
_check(BauOps.gebaeude_gruppen(doc).size() == 2, "zwei Gebäude in der Map")
var o0: Dictionary = (doc["objects"] as Array)[0]
var k0: Dictionary = (doc["objects"] as Array)[int(neue[0])]
_check(absf(float(k0["pos"][0]) - float(o0["pos"][0]) - 8.0) < 0.001, "Kopie um 8 m versetzt")
# Löschen: Gebäude ohne Teile verliert den Namen
var objects: Array = doc["objects"]
var del := GroupOps.members_of(doc, gid_kopie)
del.sort()
del.reverse()
for i in del:
objects.remove_at(i)
GroupState.prune_orphans(doc)
_check(BauOps.gebaeude_gruppen(doc).size() == 1, "nach Löschen: 1 Gebäude")
_check(not GroupState.names_dict(doc).has(str(gid_kopie)), "Gruppenname der gelöschten Kopie ist weg")
func _test_save_load() -> void:
print("Speichern / Laden")
var doc := _neue_doc()
BauOps.haus_demo(doc, null, Vector3(4.0, 0.0, 4.0))
var n := (doc["objects"] as Array).size()
var res := SvmapIO.save_to_file("user://test_bauen.svmap", doc)
_check(bool(res["ok"]), "Map mit Haus speichern (Validierung V1–V17 grün)")
var back := SvmapIO.load_from_file("user://test_bauen.svmap")
_check(bool(back["ok"]), "…und wieder laden")
if bool(back["ok"]):
var d: Dictionary = back["data"]
_check((d["objects"] as Array).size() == n, "alle %d Teile nach dem Laden da" % n)
_check(BauOps.gebaeude_gruppen(d).size() == 1, "Gebäude-Gruppe erhalten")
_check(GroupState.display_name(d, 0).begins_with("Gebäude"), "Gebäude-Name erhalten")
_check(not d.has("gebaeude"), "keine alte gebaeude-Sektion mehr")
# Poly-Rechnung: Bauteile zählen im Budget wie jedes Objekt
var manifest: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://library/objects.json"))
var poly := SvmapDocument.poly_usage(doc, manifest as Dictionary)
_check(absf(poly - float(BauOps.dreiecke_gesamt(doc))) < 0.5, "Poly-Budget = Summe der Bauteil-Dreiecke (%d)" % int(poly))
func _test_migration() -> void:
print("Migration alte gebaeude-Sektion")
var doc := _neue_doc()
doc["gebaeude"] = [
{"id": "fundament", "pos": [3.0, 0.0, 3.0], "rot": 0.0, "punkte": []},
{"id": "wand", "pos": [2.0, 0.2, 2.0], "rot": 0.0, "punkte": []},
{"id": "tuer", "pos": [4.0, 0.2, 4.0], "rot": 90.0, "punkte": []},
{"id": "unbekannt", "pos": [0.0, 0.0, 0.0], "rot": 0.0},
]
var n := BauOps.migriere_gebaeude(doc)
_check(n == 3, "3 bekannte Teile übernommen (unbekannte ignoriert)")
_check(not doc.has("gebaeude"), "alte Sektion entfernt")
var objs: Array = doc["objects"]
var wand: Dictionary = {}
for o in objs:
if str(o["id"]) == "bau_wand":
wand = o
_check(not wand.is_empty() and absf(float(wand["pos"][0]) - 3.0) < 0.001 and absf(float(wand["pos"][2]) - 2.0) < 0.001,
"Wand-Ursprung wandert in die Wandmitte (2,2 → 3,2)")
var tuer: Dictionary = {}
for o in objs:
if str(o["id"]) == "bau_wand_tuer":
tuer = o
_check(not tuer.is_empty() and absf(float(tuer["pos"][0]) - 4.0) < 0.001 and absf(float(tuer["pos"][2]) - 3.0) < 0.001,
"gedrehte Tür-Wand (90°): Mitte (4, 3)")
_check(BauOps.gebaeude_gruppen(doc).size() == 1, "alle Teile in einem Gebäude")
_check(BauOps.migriere_gebaeude(doc) == 0, "zweiter Lauf tut nichts")
var leer := _neue_doc()
leer["gebaeude"] = []
_check(BauOps.migriere_gebaeude(leer) == 0 and not leer.has("gebaeude"), "leere Sektion wird still entfernt")