Die harte Erwartung lib.size() == 54 (15 Basis + 39 Natur-Update) brach bei JEDER Kategorie-Karte: schon der A6-Stand hatte 56 (2 Beispiel-Objekte), nach D1-D5 sind es 317. Der Check ist jetzt datengetrieben: - Basis-Bestand bleibt garantiert: lib.size() >= 54 (Merge-Fundament A6) — der Katalog darf nur wachsen, schrumpfen failt weiterhin. - Neu: Registry <-> Thumbnails deckungsgleich (je OID ein 128px-Thumb, keine verwaisten Thumbs) — fängt vergessene/abgebrochene sync_objects-Läufe. - Neu: OID-Vergleich gegen die gemergte Match-Server-Registry (../scopedvr-match-server, Quelle der Wahrheit aus objects.d/*), wenn das Sibling-Repo liegt; ohne Sibling verkürzt sich der Test statt zu failen. - Testgruppe Registry-Fußpunkt: "alle 54 Objekte" -> dynamische Zahl im Titel. Suite grün auf main: 95/95 checks (ist 317). Beweis Drift-Erkennung: eine injizierte Fremd-OID in der MS-Registry failt den neuen Vergleich gezielt.
570 lines
26 KiB
GDScript
570 lines
26 KiB
GDScript
extends SceneTree
|
||
## Editor-Tests (headless): Karte t_3a998ae4 — Objekt-Bibliothek, Snap-to-Ground,
|
||
## Raster-Snap, svmap-Roundtrip mit Objekten.
|
||
## Ausführen: godot --headless --path . --script res://tests/test_library.gd
|
||
## Exit 0 = alles grün. Läuft OHNE UI (LibraryBrowser/ObjectOps/SvmapIO/SvmapDocument).
|
||
## UI-Funktionen (Gruppen-Aufklappen, Thumbnail-Anzeige) werden im Desktop-Smoke
|
||
## verifiziert (--library-demo), nicht hier.
|
||
|
||
var failures := 0
|
||
var checks := 0
|
||
|
||
|
||
func _init() -> void:
|
||
_run_tests()
|
||
_finish()
|
||
|
||
|
||
## Die Ghost-Gruppe braucht einen Viewport im Baum (Kamera-Projektion) —
|
||
## erst zwei Frames warten (wie test_terrain/_gui_smoke), dann fahren.
|
||
func _finish() -> void:
|
||
await process_frame
|
||
await process_frame
|
||
_test_ghost_matches_placement()
|
||
if failures == 0:
|
||
print("ALL OK: %d checks" % checks)
|
||
quit(0)
|
||
else:
|
||
print("FAILED: %d/%d checks" % [failures, checks])
|
||
quit(1)
|
||
|
||
|
||
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_manifest_load()
|
||
_test_groups()
|
||
_test_thumbnails()
|
||
_test_grid_snap()
|
||
_test_snap_to_ground()
|
||
_test_snap_to_ground_undo()
|
||
_test_crud_undo_all_objects()
|
||
_test_svmap_roundtrip_objects()
|
||
_test_placement_foot_convention()
|
||
_test_mesh_foot_point_registry()
|
||
|
||
|
||
# ------------------------------------------------------- Bibliothek laden ----
|
||
|
||
func _load_manifest() -> Dictionary:
|
||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
|
||
return parsed if parsed is Dictionary else {}
|
||
|
||
|
||
## Gemergte Registry aus dem Match-Server-Sibling (Quelle der Wahrheit —
|
||
## library/objects.json entsteht dort aus den Fragmenten objects.d/*.json,
|
||
## tools/sync_objects.sh zieht die Kopie hierher). Leer, wenn das Sibling
|
||
## fehlt (CI/Klon ohne Nachbar-Repo) — der Test verkürzt sich dann statt zu failen.
|
||
func _match_server_registry() -> Dictionary:
|
||
var base := ProjectSettings.globalize_path("res://")
|
||
for k in [base + "../scopedvr-match-server", base + "../../scopedvr-match-server"]:
|
||
var path: String = k + "/library/objects.json"
|
||
if FileAccess.file_exists(path):
|
||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||
return parsed if parsed is Dictionary else {}
|
||
return {}
|
||
|
||
|
||
func _test_manifest_load() -> void:
|
||
print("Testgruppe: Bibliothek laden (library/objects.json)")
|
||
var manifest := _load_manifest()
|
||
_check(not manifest.is_empty(), "Manifest lädt und ist ein Objekt")
|
||
var lib: Dictionary = manifest.get("objects", {})
|
||
# Datengetrieben statt harte Zahl: Der Basis-Bestand (54 Objekte aus dem
|
||
# Merge-Fundament A6) muss IMMER erhalten bleiben — der Katalog darf nur
|
||
# wachsen (A6-Beispiele, D1 Wände, D2 Absperrungen, D3 Gebäude, D4 Props,
|
||
# D5 Natur, ...). Eine exakte Zahl hier bricht bei jeder Kategorie-Karte.
|
||
_check(lib.size() >= 54, "Katalog enthält mindestens die 54 Basis-Objekte (ist %d)" % lib.size())
|
||
# Registry ↔ Thumbnails deckungsgleich: sync_objects.sh zieht objects.json
|
||
# UND rendert je Objekt einen Thumb. Drift (Objekt ohne Thumb oder verwaister
|
||
# Thumb) zeigt einen nicht gelaufenen/abgebrochenen Sync.
|
||
var fehlen_thumb: Array = []
|
||
var verwaist_thumb: Array = []
|
||
var tdir := DirAccess.open("res://library/thumbs")
|
||
if tdir != null:
|
||
var thumbs: Dictionary = {}
|
||
tdir.list_dir_begin()
|
||
var f := tdir.get_next()
|
||
while not f.is_empty():
|
||
if f.ends_with(".png"):
|
||
thumbs[f.get_basename()] = true
|
||
f = tdir.get_next()
|
||
tdir.list_dir_end()
|
||
for oid in lib.keys():
|
||
if not thumbs.has(str(oid)):
|
||
fehlen_thumb.append(str(oid))
|
||
for t in thumbs.keys():
|
||
if not lib.has(t):
|
||
verwaist_thumb.append(str(t))
|
||
_check(fehlen_thumb.is_empty() and verwaist_thumb.is_empty(),
|
||
"Registry und Thumbnails deckungsgleich (fehlen %d, verwaist %d)" % [fehlen_thumb.size(), verwaist_thumb.size()])
|
||
# Alle OIDs der Match-Server-Fragmente sind in der Editor-Kopie (wenn das
|
||
# Sibling-Repo neben dem Editor liegt, wie in tools/sync_objects.sh).
|
||
var ms := _match_server_registry()
|
||
if not ms.is_empty():
|
||
var ms_ids: Dictionary = ms.get("objects", {})
|
||
var fehlt_editor: Array = []
|
||
var fehlt_ms: Array = []
|
||
for oid in ms_ids.keys():
|
||
if not lib.has(str(oid)):
|
||
fehlt_editor.append(str(oid))
|
||
for oid in lib.keys():
|
||
if not ms_ids.has(str(oid)):
|
||
fehlt_ms.append(str(oid))
|
||
_check(fehlt_editor.is_empty() and fehlt_ms.is_empty(),
|
||
"Editor-Registry deckt alle OIDs des Match-Server-Merges ab (fehlen Editor %d / MS %d)" % [fehlt_editor.size(), fehlt_ms.size()])
|
||
var all_ok := true
|
||
for oid in lib.keys():
|
||
var entry: Dictionary = lib[oid]
|
||
if int(entry.get("poly", 0)) <= 0:
|
||
all_ok = false
|
||
var col: Dictionary = entry.get("collision", {})
|
||
if str(col.get("shape", "")).is_empty():
|
||
all_ok = false
|
||
_check(all_ok, "jedes Objekt hat poly > 0 + Kollider-Eintrag")
|
||
# Anzeigenamen decken ALLE IDs ab — feste Übersetzung (NAMES) ODER der
|
||
# automatische Fallback (ID großschreiben). Neue Pipeline-Objekte brauchen
|
||
# daher KEINEN Editor-Quellcode-Touch mehr.
|
||
var names_ok := lib.keys().all(func(oid) -> bool:
|
||
return not LibraryBrowser.object_label(str(oid)).is_empty())
|
||
_check(names_ok, "deutsche Namen für alle IDs vorhanden")
|
||
_check(LibraryBrowser.object_label("crate_wood") == "Holzkiste", "object_label: crate_wood → Holzkiste")
|
||
_check(LibraryBrowser.object_label("gibts_nicht") == "Gibts Nicht", "object_label: unbekannte ID fällt auf capitalize zurück")
|
||
|
||
|
||
func _test_groups() -> void:
|
||
print("Testgruppe: Gruppen (Gruppe/Einzelobjekt im Browser)")
|
||
var manifest := _load_manifest()
|
||
var lib: Dictionary = manifest.get("objects", {})
|
||
var groups := LibraryBrowser.groups_in_order(manifest)
|
||
var seen := {}
|
||
var all_in := true
|
||
for group in groups:
|
||
_check(not str(group["label"]).is_empty(), "Gruppe „%s“ hat Label" % str(group["key"]))
|
||
for oid in (group["ids"] as Array):
|
||
if seen.has(oid):
|
||
all_in = false
|
||
seen[oid] = true
|
||
_check(all_in, "keine ID in zwei Gruppen")
|
||
_check(seen.size() == lib.size(), "alle %d IDs landen in einer Gruppe (ist %d)" % [lib.size(), seen.size()])
|
||
_check(LibraryBrowser.group_of("tree_pine") == "bäume", "group_of: tree_pine → bäume (Natur-Update hat 'natur' aufgelöst)")
|
||
_check(LibraryBrowser.group_of("crate_wood") == "kisten", "group_of: crate_wood → kisten")
|
||
_check(LibraryBrowser.group_of("baum_kiefer_1") == "bäume", "group_of: baum_kiefer_1 → bäume")
|
||
_check(LibraryBrowser.group_of("fels_moos_platte") == "felsen", "group_of: fels_moos_platte → felsen")
|
||
_check(LibraryBrowser.group_of("gras_kurz") == "gras", "group_of: gras_kurz → gras")
|
||
_check(LibraryBrowser.group_of("pilz_morchel") == "pilze", "group_of: pilz_morchel → pilze")
|
||
_check(LibraryBrowser.group_of("fremdes_ding") == "sonstiges", "group_of: unbekannte ID → sonstiges")
|
||
var vorhanden := {}
|
||
for oid in lib.keys():
|
||
vorhanden[str(oid)] = true
|
||
var gruppen_komplett := true
|
||
for key in LibraryBrowser.GROUP_IDS:
|
||
if key == "sonstiges":
|
||
continue
|
||
for oid in (LibraryBrowser.GROUP_IDS[key] as Array):
|
||
if not vorhanden.has(str(oid)):
|
||
gruppen_komplett = false
|
||
_check(gruppen_komplett, "jede feste Gruppe listet nur IDs, die es im Katalog gibt")
|
||
# Gruppen, die im Katalog leer sind, werden nicht angezeigt (außer Sonstiges).
|
||
var labels: Array = []
|
||
for group in groups:
|
||
labels.append(str(group["label"]))
|
||
var no_empty := true
|
||
for group in groups:
|
||
if str(group["key"]) != "sonstiges" and (group["ids"] as Array).is_empty():
|
||
no_empty = false
|
||
_check(no_empty, "leere feste Gruppen tauchen nicht auf")
|
||
|
||
|
||
func _test_thumbnails() -> void:
|
||
print("Testgruppe: Thumbnails (128 px, einmalig gerendert + committed)")
|
||
var manifest := _load_manifest()
|
||
var lib: Dictionary = manifest.get("objects", {})
|
||
var missing: Array = []
|
||
var wrong_size := false
|
||
for oid in lib.keys():
|
||
var path := LibraryBrowser.thumb_path(str(oid))
|
||
if not FileAccess.file_exists(path):
|
||
missing.append(str(oid))
|
||
continue
|
||
var img := Image.load_from_file(path)
|
||
if img == null or img.get_width() != 128 or img.get_height() != 128:
|
||
wrong_size = true
|
||
_check(missing.is_empty(), "Thumbnail für jedes Objekt vorhanden (fehlend: %s)" % str(missing))
|
||
_check(not wrong_size, "alle Thumbnails sind 128×128")
|
||
var tex := LibraryBrowser.load_thumb("crate_wood", 40)
|
||
_check(tex != null, "load_thumb liefert Textur")
|
||
var fallback := LibraryBrowser.load_thumb("gibts_nicht")
|
||
_check(fallback == null, "load_thumb: fehlendes Thumbnail → null (Button fällt aufs Text-Layout zurück)")
|
||
|
||
|
||
# ------------------------------------------------------------ Raster-Snap ----
|
||
|
||
func _test_grid_snap() -> void:
|
||
print("Testgruppe: Raster-Snap (1 m)")
|
||
_check(ObjectOps.snap_coord(10.4, true) == 10.0, "10.4 rastet auf 10")
|
||
_check(ObjectOps.snap_coord(10.6, true) == 11.0, "10.6 rastet auf 11")
|
||
_check(ObjectOps.snap_coord(-0.6, true) == -1.0, "-0.6 rastet auf -1")
|
||
_check(ObjectOps.snap_coord(-0.4, true) == 0.0, "-0.4 rastet auf 0")
|
||
_check(ObjectOps.snap_coord(10.4, false) == 10.4, "aus = unverändert")
|
||
_check(ObjectOps.snap_coord(10.4, true, 4.0) == 12.0, "4-m-Raster: 10.4 → 12")
|
||
|
||
|
||
# ---------------------------------------------------------- Snap to Ground ---
|
||
|
||
## Alle Heightmap-Samples auf einen 16-bit-Wert setzen → Terrain überall
|
||
## gleich hoch (h = min + v/65535 × Spannweite). Für Snap-Tests ohne
|
||
## Terrain-Werkzeug. uint16 little-endian (Low-Byte zuerst, map_format.md).
|
||
func _flatten_terrain(doc: Dictionary, v: int) -> void:
|
||
var terrain: Dictionary = doc["terrain"]
|
||
var res := int(terrain["resolution"])
|
||
var bytes := PackedByteArray()
|
||
bytes.resize(res * res * 2)
|
||
for i in res * res:
|
||
bytes[i * 2] = v & 0xFF
|
||
bytes[i * 2 + 1] = (v >> 8) & 0xFF
|
||
terrain["heightmap_r16_b64"] = Marshalls.raw_to_base64(bytes)
|
||
|
||
|
||
func _test_snap_to_ground() -> void:
|
||
print("Testgruppe: Snap to Ground (alle schwebenden Objekte auf Terrain-Höhe)")
|
||
var doc := SvmapDocument.create_new("Snap-Test", 1024.0, "mittag")
|
||
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||
(doc["objects"] as Array).append({"id": "tree_pine", "pos": [-5.0, 0.0, 3.0], "scale": 1.0, "rot_y_deg": 45.0})
|
||
_check(ObjectOps.snap_to_ground(doc) == 0, "flaches Terrain: alles steht schon (0 Änderungen)")
|
||
_flatten_terrain(doc, 65535) # Terrain überall auf max_height (50 m)
|
||
var h := SvmapDocument.terrain_height_at(doc, 10.0, 10.0)
|
||
_check(absf(h - 50.0) < 0.01, "Terrain-Höhe nach Heben: %.1f m" % h)
|
||
var changed := ObjectOps.snap_to_ground(doc)
|
||
_check(changed == 2, "2 schwebende Objekte erkannt")
|
||
var objects: Array = doc["objects"]
|
||
_check(absf(float(objects[0]["pos"][1]) - 50.0) < 0.01, "Objekt 1 steht auf dem Boden")
|
||
_check(absf(float(objects[1]["pos"][1]) - 50.0) < 0.01, "Objekt 2 steht auf dem Boden")
|
||
_check(ObjectOps.snap_to_ground(doc) == 0, "zweiter Klick: nichts mehr zu tun")
|
||
# Teilweise schwebend: nur das schwebende wird erfasst.
|
||
objects[0]["pos"][1] = 50.0
|
||
objects[1]["pos"][1] = 60.0
|
||
_check(ObjectOps.snap_to_ground(doc) == 1, "nur das schwebende Objekt wird gesnapt")
|
||
|
||
|
||
func _test_snap_to_ground_undo() -> void:
|
||
print("Testgruppe: Snap to Ground = EIN Undo-Eintrag")
|
||
var doc := SvmapDocument.create_new("Snap-Undo", 1024.0, "mittag")
|
||
var hist := ActionHistory.new()
|
||
hist.begin(doc)
|
||
(doc["objects"] as Array).append({"id": "bunker_concrete", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||
(doc["objects"] as Array).append({"id": "fence_metal", "pos": [4.0, 0.0, -2.0], "scale": 1.0, "rot_y_deg": 90.0})
|
||
_flatten_terrain(doc, 32768) # Terrain auf ~25 m
|
||
var before: Dictionary = hist.snapshot(["objects"])
|
||
var changed := ObjectOps.snap_to_ground(doc)
|
||
hist.commit("Alle Objekte auf Boden gesetzt (%d)" % changed, before, hist.snapshot(["objects"]))
|
||
var objects: Array = doc["objects"]
|
||
_check(changed == 2, "beide Objekte gesnapt")
|
||
_check(absf(float(objects[0]["pos"][1]) - 25.0) < 0.05, "Objekt 1 auf Terrain-Höhe")
|
||
hist.undo()
|
||
_check(absf(float(objects[0]["pos"][1]) - 0.0) < 0.0001, "undo: beide wieder schwebend (1 Schritt)")
|
||
_check(absf(float(objects[1]["pos"][1]) - 0.0) < 0.0001, "undo: Objekt 2 auch")
|
||
hist.redo()
|
||
_check(absf(float(objects[0]["pos"][1]) - 25.0) < 0.05 and absf(float(objects[1]["pos"][1]) - 25.0) < 0.05, "redo: wieder auf dem Boden")
|
||
|
||
|
||
# ------------------------------------------- setzen/drehen/verschieben/löschen —
|
||
|
||
func _test_crud_undo_all_objects() -> void:
|
||
print("Testgruppe: JEDES Bibliotheks-Objekt — setzen/drehen/verschieben/löschen + je Undo")
|
||
var manifest := _load_manifest()
|
||
var lib: Dictionary = manifest.get("objects", {})
|
||
var doc := SvmapDocument.create_new("CRUD-Test", 1024.0, "mittag")
|
||
var hist := ActionHistory.new()
|
||
hist.begin(doc)
|
||
var all_ok := true
|
||
var first := true
|
||
for oid in lib.keys():
|
||
var x := randf() * 100.0 - 50.0
|
||
var z := randf() * 100.0 - 50.0
|
||
var y := SvmapDocument.terrain_height_at(doc, x, z)
|
||
# setzen (+ Undo)
|
||
var before := hist.snapshot(["objects"])
|
||
var obj := {"id": oid, "pos": [x, y, z], "scale": 1.0, "rot_y_deg": 0.0}
|
||
(doc["objects"] as Array).append(obj)
|
||
hist.commit("Test: %s platzieren" % oid, before, hist.snapshot(["objects"]))
|
||
var objects: Array = doc["objects"]
|
||
if objects.size() != 1 or absf(float(objects[0]["pos"][1]) - y) > 0.0001:
|
||
all_ok = false
|
||
hist.undo()
|
||
if not (doc["objects"] as Array).is_empty():
|
||
all_ok = false
|
||
hist.redo()
|
||
if (doc["objects"] as Array).size() != 1:
|
||
all_ok = false
|
||
# drehen (+ Undo)
|
||
before = hist.snapshot(["objects"])
|
||
objects[0]["rot_y_deg"] = 90.0
|
||
hist.commit("Test: drehen", before, hist.snapshot(["objects"]), "rot:0")
|
||
hist.undo()
|
||
if absf(float(objects[0]["rot_y_deg"]) - 0.0) > 0.0001:
|
||
all_ok = false
|
||
hist.redo()
|
||
if absf(float(objects[0]["rot_y_deg"]) - 90.0) > 0.0001:
|
||
all_ok = false
|
||
# verschieben (+ Undo)
|
||
before = hist.snapshot(["objects"])
|
||
objects[0]["pos"] = [x + 3.0, y, z + 3.0]
|
||
hist.commit("Test: verschieben", before, hist.snapshot(["objects"]), "move:0")
|
||
hist.undo()
|
||
if absf(float(objects[0]["pos"][0]) - x) > 0.0001:
|
||
all_ok = false
|
||
hist.redo()
|
||
if absf(float(objects[0]["pos"][0]) - (x + 3.0)) > 0.0001:
|
||
all_ok = false
|
||
# löschen (+ Undo)
|
||
before = hist.snapshot(["objects"])
|
||
(doc["objects"] as Array).remove_at(0)
|
||
hist.commit("Test: löschen", before, hist.snapshot(["objects"]))
|
||
hist.undo()
|
||
if (doc["objects"] as Array).size() != 1:
|
||
all_ok = false
|
||
hist.redo()
|
||
if not (doc["objects"] as Array).is_empty():
|
||
all_ok = false
|
||
if first:
|
||
first = false
|
||
_check(all_ok, "alle %d Objekte: setzen/drehen/verschieben/löschen je mit Undo+Redo sauber" % lib.size())
|
||
|
||
|
||
# -------------------------------------------------------- svmap-Roundtrip ----
|
||
|
||
func _test_svmap_roundtrip_objects() -> void:
|
||
print("Testgruppe: svmap-Roundtrip — Objekte (ID + Pos/Rot/Scale) überleben Speichern/Laden")
|
||
var doc := SvmapDocument.create_new("Roundtrip", 1024.0, "abend")
|
||
_flatten_terrain(doc, 20000) # Terrain leicht erhöht (~15 m)
|
||
var objects: Array = doc["objects"]
|
||
objects.append({"id": "watchtower_wood", "pos": [10.0, SvmapDocument.terrain_height_at(doc, 10.0, 6.0), 6.0], "scale": 1.0, "rot_y_deg": 30.0})
|
||
objects.append({"id": "crate_wood", "pos": [-8.0, SvmapDocument.terrain_height_at(doc, -8.0, -4.0), -4.0], "scale": 2.0, "rot_y_deg": 0.0})
|
||
objects.append({"id": "tree_pine", "pos": [-20.0, SvmapDocument.terrain_height_at(doc, -20.0, 18.0), 18.0], "scale": 1.0, "rot_y_deg": 75.0})
|
||
objects.append({"id": "rock_large", "pos": [24.0, SvmapDocument.terrain_height_at(doc, 24.0, -18.0), -18.0], "scale": 0.5, "rot_y_deg": 180.0})
|
||
objects.append({"id": "bunker_concrete", "pos": [0.0, SvmapDocument.terrain_height_at(doc, 0.0, 40.0), 40.0], "scale": 1.0, "rot_y_deg": 270.0})
|
||
var manifest := _load_manifest()
|
||
SvmapIO.sign_map(doc) # Prüfsumme setzen → validate_map (inkl. V13) prüfbar
|
||
_check(SvmapIO.validate_map(doc, manifest).is_empty(), "Map mit 5 Objekten validiert sauber (V1–V14)")
|
||
|
||
var save1 := SvmapIO.save_to_file("user://test_library_roundtrip.svmap", doc)
|
||
_check(save1["ok"], "save_to_file ok: %s" % str(save1["errors"]))
|
||
var load1 := SvmapIO.load_from_file("user://test_library_roundtrip.svmap")
|
||
_check(load1["ok"], "load_from_file ok: %s" % str(load1["errors"]))
|
||
var doc2: Dictionary = load1["data"]
|
||
var objs2: Array = doc2["objects"]
|
||
var same := objs2.size() == objects.size()
|
||
if same:
|
||
for i in objects.size():
|
||
var a: Dictionary = objects[i]
|
||
var b: Dictionary = objs2[i]
|
||
if str(a["id"]) != str(b["id"]):
|
||
same = false
|
||
for k in 3:
|
||
if absf(float(a["pos"][k]) - float(b["pos"][k])) > 0.0001:
|
||
same = false
|
||
if absf(float(a["rot_y_deg"]) - float(b["rot_y_deg"])) > 0.0001:
|
||
same = false
|
||
if absf(float(a["scale"]) - float(b["scale"])) > 0.0001:
|
||
same = false
|
||
_check(same, "alle Objekte identisch zurück (ID, Pos, Rot, Scale)")
|
||
_check(SvmapIO.validate_map(doc2, manifest).is_empty(), "geladene Map validiert erneut sauber")
|
||
var save2 := SvmapIO.save_to_file("user://test_library_roundtrip2.svmap", doc2)
|
||
_check(save2["ok"], "zweites Speichern ok")
|
||
var text1 := FileAccess.get_file_as_string("user://test_library_roundtrip.svmap")
|
||
var text2 := FileAccess.get_file_as_string("user://test_library_roundtrip2.svmap")
|
||
_check(text1 == text2, "save→load→save byte-identisch")
|
||
# Poly-Budget-Rechnung kommt aus dem Katalog (Σ poly × scale²).
|
||
var expected := 0.0
|
||
for o in objects:
|
||
expected += float(manifest["objects"][str(o["id"])]["poly"]) * float(o["scale"]) * float(o["scale"])
|
||
_check(absf(SvmapDocument.poly_usage(doc, manifest) - expected) < 0.01,
|
||
"Poly-Budget aus Katalog: %.0f (erwartet %.0f)" % [SvmapDocument.poly_usage(doc, manifest), expected])
|
||
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip.svmap"))
|
||
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip2.svmap"))
|
||
|
||
|
||
# --------------------------------------------- Platzier-Fußpunkt (t_4092e3a0) —
|
||
|
||
## Probe-Terrain mit bekannter Höhenform: Rampe entlang +X (an der −X-Kante 0,
|
||
## an der +X-Kante 20 m). Linear → bilinear interpolierte terrain_height_at
|
||
## liefert die exakte Höhe (R16-Quantisierung ~0,2 mm bei 20 m Spannweite).
|
||
func _ramp_doc() -> Dictionary:
|
||
var doc := SvmapDocument.create_new("Fusspunkt-Probe", 320.0, "mittag")
|
||
var terrain: Dictionary = doc["terrain"]
|
||
var res := 33
|
||
var size := 320.0
|
||
terrain["resolution"] = res
|
||
terrain["size_x_m"] = size
|
||
terrain["size_z_m"] = size
|
||
terrain["min_height_m"] = 0.0
|
||
terrain["max_height_m"] = 20.0
|
||
var heights := PackedFloat32Array()
|
||
heights.resize(res * res)
|
||
var cell := size / float(res - 1)
|
||
for zi in res:
|
||
for xi in res:
|
||
var x := -size * 0.5 + float(xi) * cell
|
||
heights[zi * res + xi] = 20.0 * (x / size + 0.5)
|
||
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 20.0)
|
||
return doc
|
||
|
||
|
||
## Der EINE Platzier-Pfad (ObjectOps.placement_foot): Fußpunkt == Terrainhöhe
|
||
## am (gerasteten) Punkt; Ghost-Pfad (geclampt) und Platzierungs-Pfad fallen
|
||
## innerhalb der Map auf DENSSELBEN Wert. Erhöhtes Terrain: Y folgt der Form.
|
||
func _test_placement_foot_convention() -> void:
|
||
print("Testgruppe: Platzier-Fußpunkt (Ghost == Platzierung == Terrainhöhe)")
|
||
var doc := _ramp_doc()
|
||
var half := Vector2(160.0, 160.0)
|
||
var probes := [Vector3(-120.0, 7.0, 0.0), Vector3(0.0, 7.0, 0.0),
|
||
Vector3(80.0, 7.0, 40.0), Vector3(-80.0, 7.0, -120.0)]
|
||
for p in probes:
|
||
var h := SvmapDocument.terrain_height_at(doc, p.x, p.z)
|
||
var foot: Vector3 = ObjectOps.placement_foot(doc, p, false, half, false)
|
||
_check(absf(foot.y - h) < 0.001,
|
||
"Fußpunkt == Terrainhöhe an (%.0f, %.0f): %.3f m" % [p.x, p.z, foot.y])
|
||
var ghost: Vector3 = ObjectOps.placement_foot(doc, p, false, half, true)
|
||
_check(ghost.distance_to(foot) < 0.0001,
|
||
"Ghost-Pfad == Platzierungs-Pfad (in der Map) bei (%.0f, %.0f)" % [p.x, p.z])
|
||
# Erhöhtes Terrain (Karte A): Platzierung folgt der Form, nicht Y=0.
|
||
var mid := ObjectOps.placement_foot(doc, Vector3(0.0, 0.0, 0.0), false, half, false)
|
||
_check(absf(mid.y - 10.0) < 0.01, "Rampe in der Mitte: Fußpunkt ≈ 10 m (ist %.3f)" % mid.y)
|
||
# Raster-Snap: X/Z ganze Meter, Y = Terrainhöhe am GERASTETEN Punkt.
|
||
var snap := ObjectOps.placement_foot(doc, Vector3(10.6, 0.0, 0.6), true, half, true)
|
||
_check(snap.x == 11.0 and snap.z == 1.0, "Raster-Snap rastet X/Z (10.6/0.6 → 11/1)")
|
||
_check(absf(snap.y - SvmapDocument.terrain_height_at(doc, 11.0, 1.0)) < 0.001,
|
||
"Y = Terrainhöhe am gerasteten Punkt")
|
||
# Außerhalb der Map: Platzierung bekommt den ungeclampten Punkt (die UI
|
||
# meldet „außerhalb“), der Ghost clampft still auf die Grenze.
|
||
var outp := ObjectOps.placement_foot(doc, Vector3(500.0, 0.0, 0.0), false, half, false)
|
||
_check(absf(outp.x - 500.0) < 0.001, "Platzierung: ungeclampt außerhalb erkennbar")
|
||
var clamped := ObjectOps.placement_foot(doc, Vector3(500.0, 0.0, 0.0), false, half, true)
|
||
_check(absf(clamped.x - 160.0) < 0.001, "Ghost: clampft still auf die Map-Grenze")
|
||
|
||
|
||
## Regression über ALLE Registry-Objekte (Fußpunkt-Konvention): die
|
||
## Platzhalter-Meshes sind so gebaut, dass ihre UNTERSEITE auf dem Objekt-
|
||
## Fußpunkt (pos.y) sitzt — kein Schweben, kein Eingraben. Der Lift, den
|
||
## Ghost UND Viewport-Bauer nutzen, kommt aus derselben Registry-Kollision
|
||
## (MeshShapes.half_height).
|
||
func _test_mesh_foot_point_registry() -> void:
|
||
print("Testgruppe: Registry-Fußpunkt — Mesh-Unterseite == pos (alle Katalog-Objekte, ist %d)" % _load_manifest().get("objects", {}).size())
|
||
var manifest := _load_manifest()
|
||
var lib: Dictionary = manifest.get("objects", {})
|
||
var bad_primitiv: Array = []
|
||
var bad_verbund: Array = []
|
||
for oid in lib.keys():
|
||
var entry: Dictionary = lib[oid]
|
||
var col: Dictionary = entry.get("collision", {})
|
||
var shape := str(col.get("shape", "box"))
|
||
var mesh := MeshShapes.make_placeholder(shape, col, 1.0)
|
||
if mesh == null:
|
||
bad_verbund.append(str(oid) + " (mesh null)")
|
||
continue
|
||
var hh := MeshShapes.half_height(mesh)
|
||
var bottom := mesh.get_aabb().position.y + hh
|
||
if shape == "compound":
|
||
# Struktur-Objekte sind in Fußpunkt-Koordinaten GEBACKEN (hh = 0):
|
||
# nichts schwebt ÜBER dem Fußpunkt; Rampenenden dürfen den Boden
|
||
# knapp berühren (bruecke_holz ≈ −0,31 m an der Rampenkante).
|
||
if absf(hh) > 0.0001 or bottom > 0.05 or bottom < -0.4:
|
||
bad_verbund.append("%s (bottom %.3f)" % [oid, bottom])
|
||
else:
|
||
if absf(bottom) > 0.05:
|
||
bad_primitiv.append("%s (bottom %.3f)" % [oid, bottom])
|
||
_check(bad_primitiv.is_empty(),
|
||
"alle Grundformen: Mesh-Unterseite == Fußpunkt (±5 cm) — %s" % str(bad_primitiv))
|
||
_check(bad_verbund.is_empty(),
|
||
"alle Verbund-Objekte: fußpunkt-gebacken (kein Schweben) — %s" % str(bad_verbund))
|
||
var crate := MeshShapes.make_placeholder("box", {"size": [1.2, 1.2, 1.2]}, 1.0)
|
||
_check(absf(MeshShapes.half_height(crate) - 0.6) < 0.0001,
|
||
"half_height(Box 1.2 m) = 0.6 — Registry-Kollision ist der Lift")
|
||
|
||
|
||
## Minimales main-Doppel für die Ghost-Gruppe (interaction braucht nur doc,
|
||
## manifest und grid_snap — keine UI).
|
||
class ProbeMain:
|
||
var doc: Dictionary = {}
|
||
var manifest: Dictionary = {}
|
||
var grid_snap := false
|
||
|
||
|
||
## Akzeptanz-Test (Karte t_4092e3a0): die ECHTE Ghost-Preview-Logik
|
||
## (interaction._ghost_update mit simulierter Kamera über der Rampe) muss
|
||
## denselben Fußpunkt liefern wie die Platzierung — Objekt-Unterseite auf
|
||
## Terrainhöhe (< 5 cm), Ghost-Y == platzierte Y.
|
||
func _test_ghost_matches_placement() -> void:
|
||
print("Testgruppe: Ghost-Preview == platziertes Objekt (simulierte Kamera)")
|
||
var doc := _ramp_doc()
|
||
var fake := ProbeMain.new()
|
||
fake.doc = doc
|
||
fake.manifest = _load_manifest()
|
||
var size := 320.0
|
||
var half := Vector2(160.0, 160.0)
|
||
var cell := size / 32.0
|
||
var vp := SubViewport.new()
|
||
vp.size = Vector2i(1280, 720)
|
||
root.add_child(vp)
|
||
var cam := Camera3D.new()
|
||
vp.add_child(cam)
|
||
cam.current = true
|
||
cam.look_at_from_position(Vector3(-95.0, 115.0, 135.0), Vector3.ZERO, Vector3.UP)
|
||
var interaction := Node3D.new()
|
||
interaction.set_script(load("res://src/editor/interaction.gd"))
|
||
vp.add_child(interaction)
|
||
interaction.setup(fake, cam, null)
|
||
interaction.set("place_oid", "crate_wood")
|
||
interaction.set("placing", true)
|
||
interaction.call("_ensure_ghost")
|
||
interaction.call("_ghost_refresh_mesh")
|
||
var targets := [Vector2(-120.0, 0.0), Vector2(0.0, 0.0),
|
||
Vector2(80.0, 40.0), Vector2(-40.0, -80.0)]
|
||
for tp in targets:
|
||
var h := SvmapDocument.terrain_height_at(doc, tp.x, tp.y)
|
||
var screen := cam.unproject_position(Vector3(tp.x, h, tp.y))
|
||
interaction.call("_ghost_update", screen)
|
||
var ghost: Node3D = interaction.get("_ghost")
|
||
var visible := ghost != null and is_instance_valid(ghost) and ghost.visible
|
||
_check(visible, "Ghost sichtbar bei Ziel %s (Cursor trifft Gelände)" % tp)
|
||
if not visible:
|
||
continue
|
||
_check(absf(ghost.position.x - tp.x) < cell and absf(ghost.position.z - tp.y) < cell,
|
||
"Ghost-Treffer == Cursor-Zelle bei %s (Abweichung %.2f/%.2f m, Zelle %.1f m)" % [
|
||
tp, absf(ghost.position.x - tp.x), absf(ghost.position.z - tp.y), cell])
|
||
var th := SvmapDocument.terrain_height_at(doc, ghost.position.x, ghost.position.z)
|
||
_check(absf(ghost.position.y - th) < 0.05,
|
||
"Ghost-Fußpunkt == Terrainhöhe (<5 cm) bei %s (Δ %.4f m)" % [tp, absf(ghost.position.y - th)])
|
||
var place_foot: Vector3 = ObjectOps.placement_foot(doc, ghost.position, false, half, false)
|
||
_check(place_foot.distance_to(ghost.position) < 0.0001,
|
||
"Platzierung an derselben Stelle == Ghost-Position (Δ %.5f m)" % place_foot.distance_to(ghost.position))
|
||
var mi: MeshInstance3D = ghost.get_child(0)
|
||
var mesh: Mesh = mi.mesh
|
||
# Welt-Unterseite = Fußpunkt (Root) + Lift (Kind) + AABB-Min (Mesh).
|
||
var bottom := ghost.position.y + mi.position.y + mesh.get_aabb().position.y
|
||
_check(absf(bottom - ghost.position.y) < 0.05,
|
||
"Ghost-Mesh-Unterseite sitzt auf dem Fußpunkt (Δ %.4f m)" % absf(bottom - ghost.position.y))
|
||
# Raster-Snap am Ghost: gerastete X/Z, Y folgt der GERASTETEN Position.
|
||
fake.grid_snap = true
|
||
var h_snap := SvmapDocument.terrain_height_at(doc, 10.0, 0.0)
|
||
var screen_snap := cam.unproject_position(Vector3(10.0, h_snap, 0.0))
|
||
interaction.call("_ghost_update", screen_snap)
|
||
var g2: Node3D = interaction.get("_ghost")
|
||
if g2 != null and g2.visible:
|
||
_check(absf(g2.position.x - roundf(g2.position.x)) < 0.0001
|
||
and absf(g2.position.z - roundf(g2.position.z)) < 0.0001,
|
||
"Ghost mit Raster-Snap: X/Z ganze Meter (%.2f/%.2f)" % [g2.position.x, g2.position.z])
|
||
var th2 := SvmapDocument.terrain_height_at(doc, g2.position.x, g2.position.z)
|
||
_check(absf(g2.position.y - th2) < 0.05,
|
||
"Ghost mit Raster-Snap: Y = Terrainhöhe am gerasteten Punkt (Δ %.4f m)" % absf(g2.position.y - th2))
|
||
else:
|
||
_check(false, "Ghost mit Raster-Snap sichtbar")
|
||
vp.free()
|