scopedvr-map-editor/tests/test_glb_view.gd
slyphax 491a47d3f1 Editor-Bibliothek auf das Military-Desert-Pack umgestellt
- 423 Objekte in 19 deutschen Kategorien (Panzer, Radfahrzeuge, Flugzeuge, Gebaeude,
  Zelte, Tuerme, Mauern, Sperren, Kisten, Technik, Felsen, Boden, Decals ...);
  Gruppen "Bauwerke" und "Kisten & Barrieren" samt Test-Asset-Namenslisten entfernt
- LibraryBrowser: Gruppen/Namen aus dem Manifest (kategorie, name_de), unbekannte
  Kategorien bekommen automatisch eine eigene Gruppe
- Thumbs neu, library/ synchronisiert (inkl. neuer Kollision)
- Neue Identitaetsprobe: Kollision ALLER Objekte Editor = Client = Server
- Tests auf Pack-Objekte umgestellt

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 16:31:28 +01:00

382 lines
18 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_9b50b231 — Echte GLB-Meshes im Editor-
## Viewport (GlbMeshStore), Platzhalter nur als Fallback.
## Ausführen: godot --headless --path . --script res://tests/test_glb_view.gd
## Exit 0 = alles grün. Prüft:
## 1. Vorhandene GLB → echtes Mesh geladen, deutlich mehr Vertices als der
## Kollisions-Platzhalter (Moosfelsen-Probe).
## 2. Normalisierung: Unterkante auf y = 0, XZ zentriert (Pack-GLBs haben
## ihren Ursprung oft in der Mitte — AABB-Unterkende wird GERECHNET).
## 3. Viewport-Rebuild: Marker-Fußpunkt sitzt auf der Terrainhöhe
## (Karte-C-Konvention b20dff2), Meta „glb“ = true, keine Grundfarbe
## über den Texturen.
## 4. Fehlende GLB → Platzhalter + Eintrag in fehlende_glbs (Statuszeile).
## 5. Cache pro OID: zweiter Abruf liefert DIESELBE Mesh-Instanz.
## 6. Pack-Texturen (Karte t_1e82f747, Stand d0d4fbd): die GLB-Flächen
## tragen ihre ORIGINAL-Texturen (GLTF löst die URIs selbst auf),
## Kante = Pack-Palette 1024²; Zuordnung pack_datei_fuer wie
## tools/PACK_TEXTURE_MAPPING.md (Client-Repo, verbindlich):
## military_base(+_00x) → Demonstration_Textures1.png (1024²),
## picture_1/2/3 → Demonstration_picture_N.png (2048² Multicam).
## Die alten Camo-Erwartungen (desert_multicam_1/2.png + geteilte
## Cache-Instanz) sind ENTFERNT — die Welt hat sich gedreht.
##
## Quelle-Fund (Karte t_1e82f747): die GLBs liegen in den NACHBAR-Repos.
## Gesucht wird wie in tools/sync_objects.sh: Env SCOPEDVR_MATCH_SERVER_DIR /
## SCOPEDVR_CLIENT_DIR zuerst, dann ../ und ../../ Siblings. Ohne Fund endet
## die Suite mit klaren SKIP-Zeilen (Exit 0) statt einer roten Zahl — die
## GLB-Prüfungen laufen, sobald ein Nachbar-Repo klonbar/abgelegt ist.
var failures := 0
var checks := 0
## Absolute Repo-Wurzel der gefundenen Nachbar-Repos (Ladereihenfolge).
var repos: Array = []
func _init() -> void:
repos = _finde_repos()
if repos.is_empty():
# Karte t_1e82f747: ohne Nachbar-Repo sind die GLB-Prüfungen hier
# nicht ausführbar — klare SKIP-Zeilen statt irreführender roter
# Zahlen. Die echten Checks laufen, sobald ein Nachbar da ist.
print("SKIP: test_glb_view — kein Nachbar-Repo mit assets/objekte gefunden.")
print("SKIP GRUND: gesucht: ../scopedvr-match-server, ../scopedvr-client, ../../… (relativ zum Editor-Repo)")
print("SKIP GRUND: sowie Env SCOPEDVR_MATCH_SERVER_DIR / SCOPEDVR_CLIENT_DIR (wie tools/sync_objects.sh).")
print("SKIP GRUND: die GLBs + Pack-Texturen liegen in den Sibling-Repos — erst klonen, dann Suite fahren.")
quit(0)
return
# Quellen konfigurierbar machen: die gefundenen Repos landen in
# ProjectSettings (GlbMeshStore.QUELLEN_SETTING) — glb_quellen sind
# Basis-relativ, deshalb ../-Pfade statt Absolutpfade.
var quellen: Array = []
for root: String in repos:
quellen.append(_relativ_zu(ProjectSettings.globalize_path("res://"),
root.path_join("assets/objekte")))
print("GLB-Quellen: %s" % ", ".join(PackedStringArray(quellen)))
ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, quellen)
GlbMeshStore.cache_leeren()
_run_tests()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
## Nachbar-Repos finden wie tools/sync_objects.sh: Env-Variablen gewinnen,
## dann ../- und ../../-Siblings relativ zum Editor-Projekt. Ein Repo zählt
## nur, wenn assets/objekte existiert (Mesh-Lager).
## (Achtung, Probe 20.10.: der Repo-Name beginnt schon mit "scopedvr-" —
## die Env-Keys sind deshalb als feste Map notiert, nicht abgeleitet.)
func _finde_repos() -> Array:
var basis := ProjectSettings.globalize_path("res://")
var env_keys := {
"scopedvr-match-server": "SCOPEDVR_MATCH_SERVER_DIR",
"scopedvr-client": "SCOPEDVR_CLIENT_DIR",
}
var out: Array = []
for name: String in env_keys:
var env_pfad := OS.get_environment(env_keys[name]).simplify_path()
if not env_pfad.is_empty() and DirAccess.dir_exists_absolute(
env_pfad.path_join("assets/objekte")):
out.append(env_pfad)
for rel: String in ["../", "../../"]:
var kandidat := basis.path_join(rel + name).simplify_path()
if DirAccess.dir_exists_absolute(kandidat.path_join("assets/objekte")) \
and not out.has(kandidat):
out.append(kandidat)
return out
## glb_quellen sind Basis-RELATIVE Pfade (GlbMeshStore._kandidaten joinkt sie
## auf res:// — String.path_join KETTET auch bei absoluten Argumenten, ein
## Absolutpfad würde also kaputt gehen). Aus einem absoluten Env-Pfad wird
## deshalb ein ../-Pfad gerechnet.
func _relativ_zu(von: String, nach: String) -> String:
var v: PackedStringArray = von.simplify_path().split("/", false)
var n: PackedStringArray = nach.simplify_path().split("/", false)
var i := 0
while i < v.size() and i < n.size() and v[i] == n[i]:
i += 1
var teile := PackedStringArray()
for _j: int in range(i, v.size()):
teile.append("..")
for k: int in range(i, n.size()):
teile.append(n[k])
if teile.is_empty():
return "."
return "/".join(teile)
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:
GlbMeshStore.cache_leeren()
_test_store_ladet_moosfelsen()
_test_store_normalisierung()
_test_store_cache()
_test_pack_tex()
_test_viewport_rebuild_echt()
_test_viewport_rebuild_fehlend()
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 {}
func _vertex_zahl(mesh: Mesh) -> int:
# Universell (PrimitiveMesh UND ArrayMesh): Dreiecks-Suppe zählen.
return mesh.get_faces().size()
func _platzhalter(oid: String) -> Mesh:
var lib: Dictionary = _load_manifest().get("objects", {})
var col: Dictionary = (lib.get(oid, {}) as Dictionary).get("collision", {})
return MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0)
# ------------------------------------------------------------ GlbMeshStore --
## Der Moosfelsen-GLB ist absichtlich Low-Poly (Registry poly = 46) — für
## das „deutlich mehr Geometrie als der Platzhalter"-Kriterium dient
## stone_025 (poly = 342) als Hoch-Poly-Probe.
const MOOS_OID := "fels_moos_block_gross"
const PROBE_OID := "stone_025"
func _test_store_ladet_moosfelsen() -> void:
print("Testgruppe: GlbMeshStore — echtes GLB laden (Moosfelsen-Probe)")
_check(not GlbMeshStore.fehlt(MOOS_OID), "GLB-Datei für %s liegt im Mesh-Lager" % MOOS_OID)
var moos := GlbMeshStore.mesh_fuer(MOOS_OID)
_check(moos != null, "echtes Moosfelsen-Mesh geladen (nicht null)")
if moos != null:
var moos_platz := _platzhalter(MOOS_OID)
_check(_vertex_zahl(moos) > _vertex_zahl(moos_platz),
"Moosfelsen: echtes GLB hat mehr Vertices als der Platzhalter (%d > %d)" % [
_vertex_zahl(moos), _vertex_zahl(moos_platz)])
_check(not GlbMeshStore.fehlt(PROBE_OID), "GLB-Datei für %s liegt im Mesh-Lager" % PROBE_OID)
var mesh := GlbMeshStore.mesh_fuer(PROBE_OID)
_check(mesh != null, "echtes Mesh geladen (nicht null)")
if mesh == null:
return
var platzhalter := _platzhalter(PROBE_OID)
_check(_vertex_zahl(mesh) > _vertex_zahl(platzhalter) * 5,
"Vertexzahl echtes GLB (%d) >> Platzhalter (%d)" % [_vertex_zahl(mesh), _vertex_zahl(platzhalter)])
_check(mesh is ArrayMesh and (mesh as ArrayMesh).get_surface_count() >= 1,
"ArrayMesh mit mindestens einer Oberfläche")
func _test_store_normalisierung() -> void:
print("Testgruppe: Normalisierung — Fußpunkt-Konvention (wie scopedvr_export)")
var mesh := GlbMeshStore.mesh_fuer(PROBE_OID)
if mesh == null:
return
var aabb := mesh.get_aabb()
_check(absf(aabb.position.y) < 0.05,
"Unterkante auf y = 0 (ist %.4f m — Pack-Ursprung in der Mitte wurde herausgerechnet)" % aabb.position.y)
_check(absf(aabb.get_center().x) < 0.05 and absf(aabb.get_center().z) < 0.05,
"XZ um den Mittelpunkt zentriert (Δ %.4f / %.4f m)" % [aabb.get_center().x, aabb.get_center().z])
_check(absf(MeshShapes.half_height(mesh)) < 0.0001,
"half_height == 0 (schon in Fußpunkt-Koordinaten gebacken — kein Extra-Lift)")
func _test_store_cache() -> void:
print("Testgruppe: Cache pro OID (kein Re-Parse je Instanz)")
var a := GlbMeshStore.mesh_fuer(PROBE_OID)
var b := GlbMeshStore.mesh_fuer(PROBE_OID)
_check(a != null and a == b, "zweiter Abruf liefert DIESELBE Mesh-Instanz (Cache)")
GlbMeshStore.cache_leeren()
_check(GlbMeshStore.mesh_fuer(PROBE_OID) != null, "cache_leeren → wird neu geladen")
# --------------------------------------------------------- Pack-Texturen ----
## Karte t_1e82f747 (Stand d0d4fbd): die Camo-Erwartungen sind überholt —
## die Pack-GLBs tragen ihre ORIGINAL-Texturen (GLTFDocument löst die externen
## URIs inzwischen selbst auf, je Parse eine eigene ImageTexture-Instanz;
## der _camo_tex_cache bleibt deshalb leer). Diese Gruppe beschreibt, was IST:
## Flächen getextured, Textur-Kante = Pack-Palette 1024², Zuordnung
## pack_datei_fuer wie tools/PACK_TEXTURE_MAPPING.md (Client-Repo).
func _test_pack_tex() -> void:
print("Testgruppe: Pack-Texturen im Live-Pfad — WYSIWYG Editor = Spiel (d0d4fbd)")
var mesh_w := GlbMeshStore.mesh_fuer("wall_001")
_check(mesh_w != null, "wall_001 (walls, military_base) geladen")
if mesh_w != null:
_pack_flachen_pruefen(mesh_w, "wall_001", 1024)
var mesh_g := GlbMeshStore.mesh_fuer("ground_001")
_check(mesh_g != null, "ground_001 (nature/ground, military_base) geladen")
if mesh_g != null:
_pack_flachen_pruefen(mesh_g, "ground_001", 1024)
# Pack-Zuordnung (pure Funktion — Quelle: tools/PACK_TEXTURE_MAPPING.md):
_check(GlbMeshStore.pack_datei_fuer("military_base") == GlbMeshStore.PACK_TEX_PALETTE,
"military_base → 1024er Palette (Demonstration_Textures1.png)")
_check(GlbMeshStore.pack_datei_fuer("military_base_003") == GlbMeshStore.PACK_TEX_PALETTE,
"military_base_NNN → Palette (Präfix-Fallback)")
_check(GlbMeshStore.pack_datei_fuer("picture_1") == GlbMeshStore.PACK_TEX_PICTURE_1,
"picture_1 → 2048er Multicam (Demonstration_picture_1.png)")
_check(GlbMeshStore.pack_datei_fuer("picture_2") == GlbMeshStore.PACK_TEX_PICTURE_2,
"picture_2 → 2048er Multicam (Demonstration_picture_2.png)")
_check(GlbMeshStore.pack_datei_fuer("gibts_nicht_material") == GlbMeshStore.PACK_TEX_PALETTE,
"unbekanntes Material → Palette (Default-Fallback)")
_pack_dateien_pruefen()
## Jede Fläche trägt eine Textur und die Kante entspricht der Pack-Palette
## (1024² für military_base-Objekte) — bewiesener Live-Stand (Probe 20.10.).
func _pack_flachen_pruefen(mesh: Mesh, oid: String, erwartete_kante: int) -> void:
var count := mesh.get_surface_count()
_check(count > 0, "%s hat Flächen (%d)" % [oid, count])
var textured := 0
var kante_ok := true
for s in count:
var tex := _mat_tex(mesh, s)
if tex == null:
continue
textured += 1
var img := tex.get_image()
if img != null and (img.get_width() != erwartete_kante
or img.get_height() != erwartete_kante):
kante_ok = false
_check(textured == count,
"%s: ALLE %d Flächen haben albedo_texture != null (getextured: %d)" % [oid, count, textured])
_check(kante_ok, "%s: Textur-Kante %d² (Pack-Palette)" % [oid, erwartete_kante])
func _mat_tex(mesh: Mesh, s: int) -> Texture2D:
var mat := mesh.surface_get_material(s)
if mat is BaseMaterial3D:
return (mat as BaseMaterial3D).albedo_texture
return null
## Die Pack-Dateien liegen im gefundenen Nachbar-Repo: Palette 1024²,
## Multicam 2048². Fehlen sie dort, ist das ein echter Bruch (die GLBs
## kommen ja aus ebendiesem Repo) und wird laut gemeldet — kein SKIP-Fall.
func _pack_dateien_pruefen() -> void:
print("Testgruppe: Pack-Dateien im Nachbar-Repo (Palette 1024² / Multicam 2048²)")
var treffer := ""
for root: String in repos:
var dir := root.path_join("assets/textures/military_desert")
if FileAccess.file_exists(dir.path_join(GlbMeshStore.PACK_TEX_PALETTE)):
treffer = dir
break
_check(not treffer.is_empty(),
"textures/military_desert im Nachbar-Repo gefunden (%s)" % treffer)
if treffer.is_empty():
return
_kante_pruefen(treffer.path_join(GlbMeshStore.PACK_TEX_PALETTE), 1024, "Palette")
_kante_pruefen(treffer.path_join(GlbMeshStore.PACK_TEX_PICTURE_1), 2048, "picture_1")
_kante_pruefen(treffer.path_join(GlbMeshStore.PACK_TEX_PICTURE_2), 2048, "picture_2")
func _kante_pruefen(pfad: String, kante: int, label: String) -> void:
var img := Image.load_from_file(pfad)
_check(img != null, "%s ladbar (%s)" % [label, pfad.get_file()])
if img == null:
return
_check(img.get_width() == kante and img.get_height() == kante,
"%s ist %d² (ist %d×%d)" % [label, kante, img.get_width(), img.get_height()])
# --------------------------------------------------------- Viewport-Rebuild --
func _proben_doc(oid: String, scale: float) -> Dictionary:
var doc := SvmapDocument.create_new("GLB-Probe", 320.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var res := 17
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"] = 4.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] = 4.0 * (x / size + 0.5) # Rampe 0..4 m
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 4.0)
doc["objects"] = [{"id": oid, "pos": [0.0, 2.0, 0.0], "rot_y_deg": 0.0, "scale": scale}]
return doc
func _test_viewport_rebuild_echt() -> void:
print("Testgruppe: Viewport-Rebuild — echtes GLB im Editor-Viewport")
var builder := Node3D.new()
builder.set_script(load("res://src/viewport/map_view_builder.gd"))
root.add_child(builder)
# pos.y bewusst FALSCH (7 statt Terrainhöhe): der Marker steht am Doc-
# Fußpunkt, die Mesh-UNTERKANTE muss trotzdem auf Terrainhöhe hängen.
builder.call("rebuild", _proben_doc(PROBE_OID, 1.0), _load_manifest())
var markers: Array = builder.get("object_markers")
_check(markers.size() == 1 and markers[0] != null, "Marker für das Objekt gebaut")
if markers.size() != 1 or markers[0] == null:
return
var marker: Node3D = markers[0]
_check(bool(marker.get_meta("glb", false)), "Meta glb = true (echtes Modell)")
var mi := marker.get_child(0) as MeshInstance3D
_check(mi.mesh != null and _vertex_zahl(mi.mesh) > _vertex_zahl(_platzhalter(PROBE_OID)) * 5,
"Viewport zeigt das ECHTE Mesh, nicht den Platzhalter")
# Fußpunkt-Konvention (Karte C, b20dff2): Unterkante des Meshes == Terrainhöhe.
var boden: float = SvmapDocument.terrain_height_at(builder.get("_doc_ref"), 0.0, 0.0)
var unten: float = marker.position.y + mi.position.y + (mi.mesh.get_aabb().position.y * mi.scale.y)
_check(absf(unten - boden) < 0.05,
"Mesh-Unterkante == Terrainhöhe (%.3f m, ist %.3f m — b20dff2-Konvention)" % [boden, unten])
_check(mi.material_override == null,
"keine Grundfarbe drüber — Texturen des echten Modells bleiben sichtbar")
# U6 (t_639135dd): Auswahl = Außenhüllen-Kontur um das Modell — das
# Grundmaterial (mit Textur) bleibt im Auswahl-Zustand KOMPLETT sichtbar,
# es gibt kein material_override mehr für die Markierung.
builder.call("set_selected_multi", [0])
_check(mi.material_override == null,
"Auswahl: Textur bleibt sichtbar (kein material_override mehr, U6)")
_check(marker.get_meta("outline", null) is MeshInstance3D,
"Auswahl: Kontur-Outline am echten GLB-Marker (U6)")
builder.call("set_selected_multi", [])
_check(mi.material_override == null, "Auswahl weg: Texturen wieder frei")
_check(not marker.has_meta("outline"), "Auswahl weg: kein Rest-Outline (U6)")
# Skalierung: scale 2.0 → AABB-Doppelte Höhe im Marker-Meta.
builder.call("rebuild", _proben_doc(PROBE_OID, 2.0), _load_manifest())
var m2: Node3D = (builder.get("object_markers") as Array)[0]
var mi2 := m2.get_child(0) as MeshInstance3D
_check(absf(mi2.scale.x - 2.0) < 0.001, "Objekt-Scale landet am Mesh (mi.scale = 2)")
_check(absf(float(m2.get_meta("half_height")) - 2.0 * MeshShapes.center_height(mi2.mesh)) < 0.01,
"pick/half_height-Metas berücksichtigen den Scale")
func _test_viewport_rebuild_fehlend() -> void:
print("Testgruppe: Fallback — fehlende GLB → Platzhalter + Hinweis")
# Seit der Military-Pack-Umstellung hat JEDES Katalog-Objekt ein Modell — der
# Fallback wird mit einem synthetischen Eintrag ohne Mesh im Lager geprüft.
var oid := "test_ohne_mesh"
var manifest_fb: Dictionary = _load_manifest()
(manifest_fb["objects"] as Dictionary)[oid] = {"poly": 12, "collision": {"shape": "box", "size": [2.0, 1.0, 1.0]}}
_check(GlbMeshStore.fehlt(oid), "fehlende OID erkannt (fehlt == true)")
var builder := Node3D.new()
builder.set_script(load("res://src/viewport/map_view_builder.gd"))
root.add_child(builder)
builder.call("rebuild", _proben_doc(oid, 1.0), manifest_fb)
var marker: Node3D = (builder.get("object_markers") as Array)[0]
_check(not bool(marker.get_meta("glb", true)), "Meta glb = false (Platzhalter)")
var mi := marker.get_child(0) as MeshInstance3D
var lib: Dictionary = manifest_fb.get("objects", {})
var col: Dictionary = (lib.get(oid, {}) as Dictionary).get("collision", {})
var platzhalter := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0)
_check(mi.mesh.get_aabb().size.distance_to(platzhalter.get_aabb().size) < 0.001,
"Platzhalter-Größe wie Registry-Kollision (Fallback aktiv)")
_check(mi.material_override != null, "Platzhalter trägt die Editor-Grundfarbe")
var fehlend: Array = builder.get("fehlende_glbs")
_check(fehlend.has(oid), "fehlende_glbs meldet die OID (Statuszeilen-Hinweis)")
_check(GlbMeshStore.fehlt("gibts_nicht_oid"), "unbekannte OID zählt als fehlend")
var fehlend_nicht: Array = builder.get("fehlende_glbs")
_check(not fehlend_nicht.has("gibts_nicht_oid"), "unbekannte OID steht NICHT in fehlende_glbs (nicht im Doc)")