From cda6f48b01292538bea98ffce356944a7ac33392 Mon Sep 17 00:00:00 2001 From: slyphax Date: Sat, 17 Oct 2026 17:23:56 +0000 Subject: [PATCH] =?UTF-8?q?GLB-View=20(t=5F9b50b231):=20echte=20Modelle=20?= =?UTF-8?q?im=20Editor-Viewport=20=E2=80=94=20Platzhalter=20nur=20als=20Fa?= =?UTF-8?q?llback.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GlbMeshStore (neu): laedt OID+.glb aus konfigurierbaren Quellen (ProjectSettings scopedvr/editor/glb_quellen, Voreinstellung = Sibling-Repos, wie gen_thumbs; plattformneutral, relative Pfade), backt ALLE MeshInstance3D der Szene in EIN normalisiertes ArrayMesh (Fusspunkt y=0, XZ zentriert — wie scopedvr_export.normalisiere), Materialien/Texturen bleiben je Oberflaeche erhalten. - Cache pro OID (Parse nur einmal; fehlende Datei ungcached, Parse-Fail negativ gecached). - map_view_builder: echtes GLB vor Platzhalter; kein half_height-Lift (schon Fusspunkt-Koordinaten), Scale ueber Node-Scale + AABB-Metas mal Scale; Auswahl-Orange nur als Override im Auswahl-Zustand (Texturen bleiben sichtbar). fehlende_glbs-Meldung an die Stats-Zeile. - interaction (Ghost-Preview): nutzt denselben echten Mesh-Pfad; Platzier-Status haengt „(Platzhalter — echte GLB fehlt)“ an. - tests/test_glb_view.gd (neu, in run_tests.sh): Moosfelsen-Probe (echtes GLB > Platzhalter-Vertices), stein_mittel_1 1026>>36, Normalisierung y=0/XZ-zentriert, Cache-Identitaet, Viewport-Rebuild Fusspunkt == Terrainhoehe (b20dff2), Fallback + fehlende_glbs. Suite: 773 checks, alle gruen. --- project.godot | 8 ++ src/editor/interaction.gd | 10 +- src/ui/main.gd | 8 ++ src/viewport/glb_mesh_store.gd | 169 ++++++++++++++++++++++++ src/viewport/glb_mesh_store.gd.uid | 1 + src/viewport/map_view_builder.gd | 43 +++++-- tests/test_glb_view.gd | 200 +++++++++++++++++++++++++++++ tests/test_glb_view.gd.uid | 1 + tools/run_tests.sh | 2 +- 9 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 src/viewport/glb_mesh_store.gd create mode 100644 src/viewport/glb_mesh_store.gd.uid create mode 100644 tests/test_glb_view.gd create mode 100644 tests/test_glb_view.gd.uid diff --git a/project.godot b/project.godot index 42f6f47..87ea766 100644 --- a/project.godot +++ b/project.godot @@ -24,3 +24,11 @@ enabled=PackedStringArray("res://addons/modular_builder/plugin.cfg") [rendering] anti_aliasing/quality/msaa_3d=2 + +[scopedvr] + +; Quellen für echte GLB-Modelle im Editor-Viewport (Karte t_9b50b231). +; Relative Pfade vom Editor-Projektordner aus — plattformneutral +; (PLAN.md Plattform-Regel): unter Windows einfach die Sibling-Repos +; neben den Editor legen oder die Pfade hier anpassen. +editor/glb_quellen=PackedStringArray("../scopedvr-match-server/assets/objekte", "../scopedvr-client/assets/objekte") diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 9a753e0..ec14037 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -189,7 +189,9 @@ func start_placement(oid: String) -> void: _ensure_ghost() _ensure_ghost_visible() var label: String = main.object_label(oid) - main.call("_set_status", "%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, Rechtsklick/ESC bricht ab." % label) + var hinweis := "" if not GlbMeshStore.fehlt(oid) \ + else " (Platzhalter — echte GLB fehlt für %s)" % oid + main.call("_set_status", "%s%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, Rechtsklick/ESC bricht ab." % [label, hinweis]) main.call("_highlight_library", oid) @@ -226,7 +228,11 @@ func _ghost_refresh_mesh() -> void: var lib: Dictionary = main.manifest.get("objects", {}) var entry: Dictionary = lib.get(place_oid, {}) var col: Dictionary = entry.get("collision", {}) - var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0) + # Echtes GLB vorziehen (Karte t_9b50b231) — die Vorschau zeigt das + # Modell, das landet, nicht nur die Kollisionsform. Ohne GLB: Platzhalter. + var mesh := GlbMeshStore.mesh_fuer(place_oid) + if mesh == null: + mesh = MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0) _ghost_mi.mesh = mesh # Gleicher Lift wie beim platzierten Objekt: Mesh-Unterseite = Fußpunkt. _ghost_mi.position.y = MeshShapes.half_height(mesh) diff --git a/src/ui/main.gd b/src/ui/main.gd index 46a72b2..1bdf464 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -2164,6 +2164,14 @@ func _refresh_stats() -> void: stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%) · Bauteile: %d" % [ count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget, BuildOps.piece_count(doc)] + # GLB-Fehlende Objekte (Karte t_9b50b231): Platzhalter laufen, aber Sly soll + # sehen, WELCHE echten Modelle im Mesh-Lager nachgelegt werden müssen. + if view_builder != null and not (view_builder.get("fehlende_glbs") as Array).is_empty(): + var fehlend: Array = view_builder.get("fehlende_glbs") + var nennung := ", ".join(fehlend.slice(0, 3)) + if fehlend.size() > 3: + nennung += " … (+%d weitere)" % (fehlend.size() - 3) + stats_objects.text += " · ⚠ GLB fehlt: %s" % nennung # Terrain-Anteil am Quest-3-Budget (res 513 = 524.288 Dreiecke, map_format.md §4) var tris := 0 var res := 0 diff --git a/src/viewport/glb_mesh_store.gd b/src/viewport/glb_mesh_store.gd new file mode 100644 index 0000000..560cbc5 --- /dev/null +++ b/src/viewport/glb_mesh_store.gd @@ -0,0 +1,169 @@ +extends RefCounted +class_name GlbMeshStore +## Echte GLB-Renderings für Bibliotheks-Objekte im Editor-Viewport +## (Karte t_9b50b231). Der Viewport-Bauer zeigt bisher nur den +## Kollisions-Platzhalter (MeshShapes.make_placeholder) — diese statische +## Loader-Schicht holt stattdessen das ECHTE Modell aus dem Mesh-Lager +## (Sibling-Repos, gleiche Quellen wie tools/gen_thumbs.gd) und fällt ohne +## GLB auf den Platzhalter zurück. +## +## Messkonvention wie addons/modular_builder/scopedvr_export.gd +## `normalisiere`: das geladene Mesh wird in Fußpunkt-Koordinaten gebacken — +## Unterkante auf y = 0, XZ um den Mittelpunkt zentriert. Pack-GLBs haben +## ihren Mesh-Ursprung oft in der Mitte, deshalb wird die AABB-Unterkante +## GERECHNET und nie blind platziert. Damit gilt wie beim Compound-Platzhalter: +## MeshShapes.half_height(mesh) == 0 und marker.position == Fußpunkt. +## +## Plattform-Regel (PLAN.md): nur relative Kandidaten-Pfade, konfigurierbar +## über ProjectSettings — läuft identisch auf Linux und Windows. +## +## Performance: EIN Parse pro OID, Ergebnis gecacht — der Rebuild bei jedem +## Platzieren/Undo liest nur noch aus dem Cache. + +## ProjectSettings-Schlüssel für die GLB-Quellen (relativ zum Editor-Projekt). +const QUELLEN_SETTING := "scopedvr/editor/glb_quellen" +## Voreinstellung = dieselben Quellen wie tools/gen_thumbs.gd (Sibling-Repos). +const DEFAULT_QUELLEN := ["../scopedvr-match-server/assets/objekte", "../scopedvr-client/assets/objekte"] + +# Cache pro OID: Mesh (normalisiert) oder `false` = GLB gefunden, aber nicht +# ladbar (Parse-Fehler — kein endloser Retry pro Rebuild). Fehlende Datei +# bleibt UNGECACHED (nur ein billiger Datei-Check pro Rebuild; legt man die +# GLB später ab, erscheint sie ohne Editor-Neustart). +static var _cache: Dictionary = {} + + +## Kandidaten-Pfade der Quellen (ProjectSettings → Fallback-Konstante). +static func quellen() -> Array: + var setting: Variant = ProjectSettings.get_setting(QUELLEN_SETTING, DEFAULT_QUELLEN) + if not (setting is Array or setting is PackedStringArray): + return DEFAULT_QUELLEN.duplicate() + var out: Array = [] + for q in setting: + if q is String and not (q as String).strip_edges().is_empty(): + out.append((q as String).strip_edges()) + return out if not out.is_empty() else DEFAULT_QUELLEN.duplicate() + + +## Echtes Mesh für eine Objekt-ID (normalisiert, Fußpunkt-Koordinaten) oder +## null = keine brauchbare GLB → Aufrufer zeigt den Platzhalter. +static func mesh_fuer(oid: String) -> Mesh: + if oid.is_empty(): + return null + if _cache.has(oid): + return _cache[oid] if _cache[oid] is Mesh else null + for quelle in quellen(): + var basis := ProjectSettings.globalize_path("res://") + var pfad := basis.path_join(quelle).path_join(oid + ".glb") + if FileAccess.file_exists(pfad): + var m := _glb_mesh(pfad) + if m != null: + _cache[oid] = m + return m + # Datei da, aber kaputt → NEGATIV cachen (Parse-Fehler wiederholt + # sich sonst bei jedem Rebuild und kostet Zeit). + _cache[oid] = false + return null + return null + + +## Liegt für die OID überhaupt eine GLB-Datei im Lager? (Billiger Check ohne +## Parse — für Statuszeilen-Hinweise beim Platzieren.) +static func fehlt(oid: String) -> bool: + if oid.is_empty(): + return true + if _cache.has(oid): + return not (_cache[oid] is Mesh) + var basis := ProjectSettings.globalize_path("res://") + for quelle in quellen(): + if FileAccess.file_exists(basis.path_join(quelle).path_join(oid + ".glb")): + return false + return true + + +## Cache leeren (Tests / nach manuellem Eingriff ins Mesh-Lager). +static func cache_leeren() -> void: + _cache.clear() + + +# ------------------------------------------------------------ Laden -------- + +## GLB OHNE Import-System laden (GLTFDocument, wie gen_thumbs) und ALLE +## MeshInstance3D der Szene in EIN ArrayMesh zusammenbacken (Jedes Teil-Node +## mit seiner Node-Transform; Materialien je Oberfläche bleiben erhalten — +## echte Texturen statt Editor-Farbe). null = Load-Fehlschlag. +static func _glb_mesh(pfad: String) -> Mesh: + var gltf := GLTFDocument.new() + var state := GLTFState.new() + if gltf.append_from_file(pfad, state) != OK: + push_warning("GlbMeshStore: GLB nicht ladbar: %s" % pfad) + return null + var szene: Node = gltf.generate_scene(state) + if szene == null: + return null + var m := _zusammenbacken(szene) + # Das Mesh ist eine Resource (RefCounted) — das Freigeben der Szene + # berührt die Referenz im Rückgabewert nicht. + szene.free() + return m + + +## Alle Mesh-Instanzen der Szene → ein ArrayMesh (Transforms angewandt, +## Materialien übernommen), dann normalisiert (Fußpunkt-Konvention). +static func _zusammenbacken(szene: Node) -> Mesh: + var roh := ArrayMesh.new() + _sammeln(szene, Transform3D.IDENTITY, roh) + if roh.get_surface_count() == 0: + return null + return _normalisiert(roh) + + +static func _sammeln(n: Node, xf: Transform3D, out: ArrayMesh) -> void: + if n is Node3D: + xf = xf * (n as Node3D).transform + if n is MeshInstance3D and (n as MeshInstance3D).mesh != null: + var m: Mesh = (n as MeshInstance3D).mesh + for s in m.get_surface_count(): + var arrays: Array = m.surface_get_arrays(s) + if arrays.is_empty() or not (arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array): + continue + var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + for i in verts.size(): + verts[i] = xf * verts[i] + arrays[Mesh.ARRAY_VERTEX] = verts + if arrays[Mesh.ARRAY_NORMAL] is PackedVector3Array: + var norms: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL] + for i in norms.size(): + norms[i] = (xf.basis * norms[i]).normalized() + arrays[Mesh.ARRAY_NORMAL] = norms + # Statisch fürs Editor-Viewport: Skin-Daten fliegen raus (sonst + # verlangt add_surface_from_arrays ein Skeleton). + arrays[Mesh.ARRAY_BONES] = null + arrays[Mesh.ARRAY_WEIGHTS] = null + out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var mat: Material = m.surface_get_material(s) + if mat != null: + out.surface_set_material(out.get_surface_count() - 1, mat) + for c in n.get_children(): + _sammeln(c, xf, out) + + +## Fußpunkt-Konvention (wie scopedvr_export.normalisiere): Unterkante y = 0, +## XZ um den AABB-Mittelpunkt zentriert. Materialien bleiben an den Flächen. +static func _normalisiert(mesh: ArrayMesh) -> ArrayMesh: + var aabb := mesh.get_aabb() + var offset := Vector3(-aabb.get_center().x, -aabb.position.y, -aabb.get_center().z) + var out := ArrayMesh.new() + out.resource_name = mesh.resource_name + for s in mesh.get_surface_count(): + var arrays: Array = mesh.surface_get_arrays(s) + if arrays.is_empty() or not (arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array): + continue + var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + for i in verts.size(): + verts[i] = verts[i] + offset + arrays[Mesh.ARRAY_VERTEX] = verts + out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var mat: Material = mesh.surface_get_material(s) + if mat != null: + out.surface_set_material(out.get_surface_count() - 1, mat) + return out diff --git a/src/viewport/glb_mesh_store.gd.uid b/src/viewport/glb_mesh_store.gd.uid new file mode 100644 index 0000000..7fbaa0f --- /dev/null +++ b/src/viewport/glb_mesh_store.gd.uid @@ -0,0 +1 @@ +uid://bdbx4rc3fx5ix diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 23bf5b3..fee3cfa 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -1,7 +1,8 @@ extends Node3D ## Baut die visuelle Repräsentation der Map im Editor-Viewport: ## Terrain-Höhenfeld-Mesh + Maßstabsgitter + Himmel/Licht -## + Platzhalter-Meshes für Bibliotheks-Objekte + Spawn-Marker mit Beschriftung. +## + Echte GLB-Meshes für Bibliotheks-Objekte (GlbMeshStore; ohne GLB = +## Platzhalter-Mesh aus dem Kollisions-Eintrag) + Spawn-Marker mit Beschriftung. ## ## ## Objekt-Marker werden in object_markers gemerkt (Meta: obj_index, ## pick_radius) — Auswahl/Werkzeuge (interaction.gd) greifen darauf zu, @@ -24,6 +25,7 @@ const WEATHER_LIGHT := { var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt) var selected_indices: Array = [] # komplette Auswahl (Karte t_f6f29d86 — Mehrfachauswahl) var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects) +var fehlende_glbs: Array = [] # OIDs ohne echte GLB (Platzhalter-Fallback — Statuszeilen-Hinweis) var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius) var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant) var gebaeude_markers: Array = [] # MeshInstance3D je Bauteil (Karte t_97ea59c5) @@ -195,10 +197,15 @@ func _add_line(mesh: ImmediateMesh, from: Vector3, to: Vector3, color: Color) -> ## Platzhalter je Objekt: Form aus dem Bibliotheks-Manifest (wie der Server- -## Kollider), Position = Objekt-Fußpunkt. Marker-Root bekommt Metas für -## Auswahl/Anfassen: obj_index, pick_radius (Kugel-Approximation), half_height. +## Kollider), Position = Objekt-Fußpunkt. ECHTES GLB-Mesh vorziehen +## (Karte t_9b50b231, GlbMeshStore): vorhanden = echtes Modell mit Texturen, +## fehlend = Platzhalter + Eintrag in fehlende_glbs (Statuszeilen-Hinweis). +## Marker-Root bekommt Metas für Auswahl/Anfassen: obj_index, pick_radius +## (Kugel-Approximation), half_height, glb (true = echtes Modell — keine +## Grundfarbe drüberlegen, sonst sind die Texturen weg). func _build_objects(objects: Array, manifest: Dictionary) -> void: var lib: Dictionary = manifest.get("objects", {}) + fehlende_glbs = [] for i in objects.size(): var obj = objects[i] if not (obj is Dictionary): @@ -211,7 +218,12 @@ func _build_objects(objects: Array, manifest: Dictionary) -> void: var entry: Dictionary = lib[oid] var col: Dictionary = entry.get("collision", {}) var scale := float(obj.get("scale", 1.0)) - var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, scale) + var mesh := GlbMeshStore.mesh_fuer(oid) + var echt := mesh != null + if not echt: + if GlbMeshStore.fehlt(oid): + fehlende_glbs.append(oid) + mesh = MeshShapes.make_placeholder(str(col.get("shape", "box")), col, scale) var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) var marker := Node3D.new() marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) @@ -219,12 +231,21 @@ func _build_objects(objects: Array, manifest: Dictionary) -> void: marker.set_meta("obj_index", i) var mi := MeshInstance3D.new() mi.mesh = mesh - mi.position.y = MeshShapes.half_height(mesh) - mi.material_override = _select_mat if _is_selected(i) else _base_mat + if echt: + # Normalisiertes GLB liegt schon in Fußpunkt-Koordinaten + # (Unterkante y = 0) → KEIN half_height-Lift; Objekt-Maßstab + # über Node-Scale. AABB-Metas mal scale (Node-Scale geht nicht + # in mesh.get_aabb() ein). + mi.scale = Vector3.ONE * scale + else: + mi.position.y = MeshShapes.half_height(mesh) + mi.material_override = _select_mat if _is_selected(i) else (null if echt else _base_mat) marker.add_child(mi) - var half := (mesh.get_aabb().size * 0.5) as Vector3 + var half: Vector3 = (mesh.get_aabb().size * (scale if echt else 1.0) * 0.5) as Vector3 marker.set_meta("pick_radius", half.length() + maxf(0.6, half.y * 0.25)) - marker.set_meta("half_height", MeshShapes.center_height(mesh)) + marker.set_meta("half_height", + MeshShapes.center_height(mesh) * (scale if echt else 1.0)) + marker.set_meta("glb", echt) object_markers.append(marker) add_child(marker) @@ -238,6 +259,9 @@ func set_selected(idx: int) -> void: ## Mehrfachauswahl (Karte t_f6f29d86): ALLE markierten Objekte färben sich ## orange — Gruppen/Mehrfachauswahl sind auf einen Blick erkennbar. +## Echte GLB-Objekte (Meta „glb") bekommen im ungewählten Zustand KEINE +## Grundfarbe drübergelegt — material_override würde die Texturen verstecken +## (Karte t_9b50b231); orange bleibt auch dort das Auswahl-Signal. func set_selected_multi(indices: Array) -> void: selected_indices = indices.duplicate() selected_index = indices.back() if not indices.is_empty() else -1 @@ -245,7 +269,8 @@ func set_selected_multi(indices: Array) -> void: if marker == null or not is_instance_valid(marker): continue var mi: MeshInstance3D = marker.get_child(0) - mi.material_override = _select_mat if _is_selected(int(marker.get_meta("obj_index", -1))) else _base_mat + var echt := bool(marker.get_meta("glb", false)) + mi.material_override = _select_mat if _is_selected(int(marker.get_meta("obj_index", -1))) else (null if echt else _base_mat) func _is_selected(idx: int) -> bool: diff --git a/tests/test_glb_view.gd b/tests/test_glb_view.gd new file mode 100644 index 0000000..b12dd53 --- /dev/null +++ b/tests/test_glb_view.gd @@ -0,0 +1,200 @@ +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. + +var failures := 0 +var checks := 0 + + +func _init() -> void: + _run_tests() + 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: + GlbMeshStore.cache_leeren() + _test_store_ladet_moosfelsen() + _test_store_normalisierung() + _test_store_cache() + _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 +## stein_mittel_1 (poly = 342) als Hoch-Poly-Probe. +const MOOS_OID := "fels_moos_block_gross" +const PROBE_OID := "stein_mittel_1" + +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") + + +# --------------------------------------------------------- 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") + # Auswahl färbt trotzdem orange (Material-Override NUR im Auswahl-Zustand). + builder.call("set_selected_multi", [0]) + _check(mi.material_override != null, "Auswahl: orange Override an") + builder.call("set_selected_multi", []) + _check(mi.material_override == null, "Auswahl weg: Texturen wieder frei") + # 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") + var oid := "crate_wood" # Basis-Katalog ohne GLB im Mesh-Lager + _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), _load_manifest()) + 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 = _load_manifest().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)") diff --git a/tests/test_glb_view.gd.uid b/tests/test_glb_view.gd.uid new file mode 100644 index 0000000..b122309 --- /dev/null +++ b/tests/test_glb_view.gd.uid @@ -0,0 +1 @@ +uid://dme0ulusw80bp diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 559e1bc..54ec886 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -3,7 +3,7 @@ cd "$(dirname "$0")/.." GODOT=~/bin/godot4 rc=0 -for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_spawns test_testrun test_bauen test_gui_smoke; do +for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_gui_smoke; do "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1 code=$? line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)