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") 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://src/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()