scopedvr-map-editor/tests/test_library.gd
2026-11-01 19:34:05 +01:00

589 lines
28 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): 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://core/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")
LibraryBrowser.setze_manifest(_load_manifest())
_check(LibraryBrowser.object_label("box_001") == str(lib["box_001"]["name_de"]) and LibraryBrowser.object_label("box_001") != "Box 001",
"object_label: box_001 → deutscher Name aus dem Manifest („%s“)" % LibraryBrowser.object_label("box_001"))
_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()])
LibraryBrowser.setze_manifest(manifest)
for paar in [["acacia_005", "baeume"], ["box_001", "kisten"], ["tank_001", "panzer"], ["rock_003", "felsen"],
["tent_001", "zelte"], ["decal_001", "decals"], ["helicopter_001", "luftfahrzeuge"],
["tank_tower_001", "fahrzeugteile"], ["barracks_001", "gebaeude"]]:
_check(LibraryBrowser.group_of(str(paar[0])) == str(paar[1]), "group_of: %s → %s" % [paar[0], paar[1]])
_check(LibraryBrowser.group_of("fremdes_ding") == "sonstiges", "group_of: unbekannte ID → sonstiges")
# Nur Military-Pack: jede Registry-Kategorie ist eine bekannte Gruppe, nichts landet in "Sonstiges".
var unbekannt: Array = []
for oid in lib.keys():
var kat := str((lib[oid] as Dictionary).get("kategorie", ""))
if not LibraryBrowser.KAT_GROUPS.has(kat):
unbekannt.append(str(oid))
_check(unbekannt.is_empty(), "jede Kategorie der Registry ist eine bekannte Gruppe (unbekannt: %s)" % str(unbekannt.slice(0, 3)))
var sonstiges_n := 0
for group in groups:
if str(group["key"]) == "sonstiges":
sonstiges_n = (group["ids"] as Array).size()
_check(sonstiges_n == 0, "keine Objekte in „Sonstiges“ (ist %d)" % sonstiges_n)
var kat_keys: Array = []
for group in groups:
kat_keys.append(str(group["key"]))
for slug in LibraryBrowser.KAT_ORDER:
_check(kat_keys.has(slug), "Gruppe „%s“ enthält Objekte" % slug)
# Alt-Gruppen sind weg (Bauwerke / Kisten & Barrieren / Pilze …).
var alte_labels := ["Bauwerke", "Kisten & Barrieren", "Pilze", "Gras", "Bäume", "Pflanzen"]
var alt_gefunden := false
for group in groups:
if alte_labels.has(str(group["label"])):
alt_gefunden = true
_check(not alt_gefunden, "keine Test-Asset-Gruppen mehr (Bauwerke, Kisten & Barrieren, Pilze …)")
# Fremde Kategorie (z. B. spätere Bauteil-Module) → eigene Gruppe vor „Sonstiges“.
var fremd := {"objects": {"bau_x": {"poly": 1, "collision": {"shape": "none"}, "kategorie": "bauteile", "name_de": "Bauteil X"}}}
var g_fremd := LibraryBrowser.groups_in_order(fremd)
_check(g_fremd.size() == 2 and str(g_fremd[0]["key"]) == "bauteile" and str(g_fremd[1]["key"]) == "sonstiges",
"unbekannte Kategorie bekommt eine eigene Gruppe vor „Sonstiges“")
# 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("box_001", 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": "box_001", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "acacia_005", "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": "checkpoint_001", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "fence_001", "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": "water_tower_004", "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": "box_001", "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": "acacia_005", "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_008", "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": "checkpoint_001", "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 (concrete_wall_001 ≈ −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://core/editor/interaction.gd"))
vp.add_child(interaction)
interaction.setup(fake, cam, null)
interaction.set("place_oid", "box_001")
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()