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"] ## Mitgelieferter Modell-Ordner (Karte t_12cf94fe): direkt neben der App ## (im Editor = Projektordner, in der exportierten App = neben der EXE). ## Der sync über tools/sync_models.sh legt hier die GLBs + Pack-Texturen ## ab — dann läuft der Map Builder auf einem fremden PC OHNE Sibling-Repos. ## Der Ordner trägt eine .gdignore (kein Import/PCK-Aufnahme; FileAccess ## mit absolutem Pfad funktioniert trotzdem). const MODELS_DIR := "models" ## Klarer Hinweistext (Karte t_12cf94fe, Auftrag 3), wenn Modelle fehlen: ## wird als Klartext-Meldung gezeigt — kein stiller Platzhalter. ## Notfall-Modus: Terrain/Bauteile brauchen keine GLBs und funktionieren immer. const FEHLT_TEXT := "Für die Anzeige fehlen die Modelle. So bekommst du sie:\n" \ + " 1. tools/sync_models.sh aus dem Map-Editor-Ordner laufen lassen\n" \ + " (kopiert die Modelle in den Ordner %s),\n" \ + " 2. oder in den Einstellungen einen Ordner mit den Objekt-GLBs setzen.\n" \ + "Terrain und Bauteile funktionieren trotzdem vollständig (Notfall-Modus)." ## Registry (A6): liefert das Feld "glb" für die Kategorie-verschachtelte Ablage. const MANIFEST_PATH := "res://library/objects.json" static var _manifest: Dictionary = {} static var _manifest_geladen := false # WYSIWYG-Fix (t_c8748efc, korrigiert in t_279df760): GLTFDocument löst externe # Textur-URIs (Sibling-Repos, außerhalb res://) NICHT auf — im Editor-Viewport # blieben die GLB-Objekte untexturiert/Editor-Farbe, während sie im Spiel # getextured sind. Flächen OHNE albedo_texture bekommen hier die Textur, die das # ITHappy-Pack für ihren MATERIALNAMEN vorsieht — Zuordnung identisch zu # tools/PACK_TEXTURE_MAPPING.md (Client-Repo, verbindlich): # military_base (+_00x) = Palette (Demonstration_Textures1.png, 1024²) # picture_1/2/3 = echtes Multicam (Demonstration_picture_N.png, 2048²) # Wichtig: Nichts wird überschrieben — GLTF-aufgelöste Texturen bleiben unangetastet. # (Der frühere Inhalt-Vergleich + Kategorie-Camo stammte aus aa94bde und hat auch # korrekte Pack-Texturen wieder durch Camo ersetzt — das war der Fehler.) const PACK_TEX_PALETTE := "Demonstration_Textures1.png" const PACK_TEX_PICTURE_1 := "Demonstration_picture_1.png" const PACK_TEX_PICTURE_2 := "Demonstration_picture_2.png" const PACK_TEX_PICTURE_3 := "Demonstration_picture_3.png" # ImageTexture pro Textur-Pfad gecacht — sonst erzeugt jede der 291 GLBs # ihre eigene 2048²-Textur (~16 MB VRAM je Stück statt 2 × 16 MB gesamt). # false = Ladeversuch fehlgeschlagen (kein endloser Retry + Warn-Flut). static var _camo_tex_cache: Dictionary = {} ## Dekodierte Multicam-Bilder (RAM, 2 × 2048² ≈ 32 MB) für den Inhalts- ## Vergleich gegen GLTF-eigene Textur-Instanzen. static var _camo_bild_cache: Dictionary = {} ## Urteil pro GLTF-Textur-Instanz (instance_id → geteilte Textur | false), ## damit der 16-MB-Datenvergleich nur einmal je Instanz läuft. static var _camo_ersetzung_cache: Dictionary = {} # 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 in Ladereihenfolge (Karte t_12cf94fe, ## Auftrag 1 — EINE Stelle, erster Treffer gewinnt): ## 1. in den Einstellungen gesetzter Pfad (QUELLEN_SETTING), ## 2. bisheriges Verhalten = Sibling-Repos (DEFAULT_QUELLEN), ## 3. mitgelieferter Ordner models/ direkt neben der App (MODELS_DIR). ## Doppelnennungen werden gefiltert (Reihenfolge bleibt erhalten); ein ## ungültiger Settings-Wert (kein Array) fällt komplett auf die Vorgabe. static func quellen() -> Array: var out: Array = [] var setting: Variant = ProjectSettings.get_setting(QUELLEN_SETTING, DEFAULT_QUELLEN) if setting is Array or setting is PackedStringArray: for q in setting: _quelle_add(out, q) for q in DEFAULT_QUELLEN: _quelle_add(out, q) _quelle_add(out, MODELS_DIR) return out ## Gültigen Quellpfad dedupliziert anhängen (typisiert, keine impliziten casts). static func _quelle_add(out: Array, q: Variant) -> void: if not (q is String): return var s := (q as String).strip_edges() if s.is_empty() or out.has(s): return out.append(s) ## Absoluter Pfad des mitgelieferten Modell-Ordners (für die Klartext- ## Meldung und tools/sync_models.sh-Verweis). Im Editor = Projektordner, ## in der exportierten App = Ordner der EXE. static func models_pfad() -> String: return ProjectSettings.globalize_path("res://").path_join(MODELS_DIR) ## Erster EXISTIERENDER Dateipfad für die OID (ohne Laden/Parse — billiger ## Datei-Check über alle Kandidaten in Quellen-Reihenfolge). Leerer String = ## kein Quellpfad hat die Datei. Für Klartext-Meldungen/Probe-Logs: zeigt, ## AUS WELCHEM Ordner die Modelle kommen. static func probe_source_fuer(oid: String) -> String: if oid.is_empty(): return "" for pfad in _kandidaten(oid): if FileAccess.file_exists(pfad): return pfad return "" ## Ausgefüllter Klartext-Hinweis (FEHLT_TEXT + echter models/-Pfad) — ## diese EINE Funktion liefert die Meldung, die die App zeigt, wenn ## Modelle fehlen (Karte t_12cf94fe, Auftrag 3). static func fehl_text() -> String: return FEHLT_TEXT % models_pfad() ## Kandidaten-Pfade (absolut) in Ladereihenfolge: Registry-`glb` (A6, Kategorie- ## Ablage assets/objekte/military_desert//.glb) VOR der flachen ## Alt-Ablage /.glb. Die Quelle ist ein Mesh-Lager (/assets/ ## objekte) — für den verschachtelten Pfad wird sie auf die Repo-Wurzel gekürzt. static func _kandidaten(oid: String) -> Array: var basis := ProjectSettings.globalize_path("res://") var out: Array = [] var rel := str(_eintrag(oid).get("glb", "")) if rel.begins_with("res://"): var sub := rel.substr(6) # Operator-Korrektur 20.10. (Eigenständigkeits-Beweis): der mitgelieferte # models/-Ordner NEBEN der App enthält die Pack-Struktur DIREKT # (models/military_desert/props/box_010.glb) — nicht unter # models/assets/objekte/. Ohne den zweiten Kandidaten unten werden ALLE # verschachtelten Pack-Objekte in der eigenständigen App nicht gefunden # (belegt: barrel_001/box_010 -> leer, fehlende_glbs=2 trotz 334 GLBs). var sub_kurz := sub.trim_prefix("assets/objekte/") for quelle in quellen(): var q := str(quelle) var marke := "/assets/objekte" var wurzel := q.substr(0, q.length() - marke.length()) if q.ends_with(marke) else q out.append(basis.path_join(wurzel).path_join(sub)) if sub_kurz != sub: out.append(basis.path_join(wurzel).path_join(sub_kurz)) for quelle in quellen(): out.append(basis.path_join(str(quelle)).path_join(oid + ".glb")) return out ## Registry-Eintrag der OID (einmal gelesen, dann gecacht). static func _eintrag(oid: String) -> Dictionary: if not _manifest_geladen: _manifest_geladen = true var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST_PATH)) if parsed is Dictionary: _manifest = (parsed as Dictionary).get("objects", {}) return _manifest.get(oid, {}) ## 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 # Bauteile (bau_*) entstehen aus dem Katalog — identisch zu den GLBs, die # der Client lädt (tools/gen_bauteile.gd), aber ohne Modell-Ordner nötig. if BauKatalog.ist_bau(oid): return BauKatalog.mesh(oid) if _cache.has(oid): return _cache[oid] if _cache[oid] is Mesh else null # Im Spiel sind die Modelle importierter Teil des Projekts. var im_projekt := _projekt_pfad(oid) if not im_projekt.is_empty(): var szene: Node = (load(im_projekt) as PackedScene).instantiate() var aus_projekt := _zusammenbacken(szene) szene.free() _cache[oid] = aus_projekt if aus_projekt != null else false return aus_projekt for pfad in _kandidaten(oid): if FileAccess.file_exists(pfad): var m := _glb_mesh(pfad) if m != null: camo_tex_setzen(m, pfad) _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 static func _projekt_pfad(oid: String) -> String: var rel := str(_eintrag(oid).get("glb", "")) if rel.begins_with("res://") and ResourceLoader.exists(rel, "PackedScene"): return rel return "" ## 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 BauKatalog.ist_bau(oid): return false if _cache.has(oid): return not (_cache[oid] is Mesh) if not _projekt_pfad(oid).is_empty(): return false for pfad in _kandidaten(oid): if FileAccess.file_exists(pfad): return false return true ## true = KEINE Quelle (Einstellungen/Siblings/models/) enthält irgend eine ## GLB-Datei → die App hat nirgends echte Modelle. Für das Klartext-Banner ## beim Start (Karte t_12cf94fe, Auftrag 3). Nicht-cachend, einmal pro Start. static func lager_ist_leer() -> bool: for quelle in quellen(): if _quelle_hat_glb(ProjectSettings.globalize_path("res://").path_join(str(quelle))): return false return true ## Enthält der Ordner (rekursiv) mindestens eine .glb-Datei? static func _quelle_hat_glb(basis: String) -> bool: var d := DirAccess.open(basis) if d == null: return false d.list_dir_begin() var name := d.get_next() while name != "": var pfad := basis.path_join(name) if d.current_is_dir(): if _quelle_hat_glb(pfad): return true elif name.ends_with(".glb"): return true name = d.get_next() return false ## Cache leeren (Tests / nach manuellem Eingriff ins Mesh-Lager). static func cache_leeren() -> void: _cache.clear() _camo_tex_cache.clear() _camo_bild_cache.clear() _camo_ersetzung_cache.clear() _manifest.clear() _manifest_geladen = false # ------------------------------------------------------------ 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 # ------------------------------------------------------- Camo-Textur -------- ## Camo-Fix (t_50a07d4b): Multicam-Textur von der Platte laden und auf alle ## Flächen ohne albedo_texture setzen (GLTFDocument löst externe URIs der ## Sibling-Repos nicht auf). Zuordnung exakt wie tools/CAMO_TEXTURE_MAPPING.md ## bzw. gen_thumbs._camo_tex_setzen — diese Funktion ist ab jetzt DIE EINE ## Quelle der Zuordnung (gen_thumbs delegiert hierher). Objekte außerhalb ## military_desert//.glb bleiben unangetastet. Fehlt die ## Textur-Datei, wird klar gewarnt (push_warning) statt still weiß zu rendern. ## ## EINE-Quelle-Dedup (t_50a07d4b): GLTFDocument lädt externe Texturen ## inzwischen selbst — aber als EIGENE ImageTexture-Instanz JEDES Parses ## (291 GLBs = 291 × 16 MB VRAM statt 2 × 16 MB, bewiesen per Probe: ## iid pro Parse unterschiedlich, Inhalt identisch zur Datei). Deshalb wird ## hier ZUSÄTZLICH geprüft: stimmt der Inhalt einer GLTF-Textur mit einer ## Multicam-Datei überein, ersetzen wir sie durch DIE eine geteilte Instanz ## aus dem Cache — gleiche Optik (WYSIWYG bleibt), eine Quelle statt Kopien. ## Eingebettete/Fremd-Texturen (Alt-Bestand, Waffen-GLBs) bleiben unangetastet. static func camo_tex_setzen(mesh: Mesh, glb_pfad: String) -> void: # Siehe Kopf-Kommentar: nur Flächen OHNE aufgelöste Textur werden versorgt. var tex_dir := glb_pfad.get_base_dir().path_join("../../../textures/military_desert") for s in mesh.get_surface_count(): var mat := mesh.surface_get_material(s) if not (mat is BaseMaterial3D): continue var bm := mat as BaseMaterial3D if bm.albedo_texture != null: continue var datei := pack_datei_fuer(bm.resource_name) var tex := _camo_tex(tex_dir.path_join(datei)) if tex != null: bm.albedo_texture = tex else: push_warning("GlbMeshStore: Pack-Textur fehlt fuer Material '%s': %s" % [bm.resource_name, datei]) ## Materialname (GLTF) -> Pack-Texturdatei. Quelle: tools/PACK_TEXTURE_MAPPING.md (Client). static func pack_datei_fuer(material_name: String) -> String: match material_name: "picture_1": return PACK_TEX_PICTURE_1 "picture_2": return PACK_TEX_PICTURE_2 "picture_3": return PACK_TEX_PICTURE_3 "military_base": return PACK_TEX_PALETTE if material_name.begins_with("military_base"): return PACK_TEX_PALETTE return PACK_TEX_PALETTE static func _camo_image(pfad: String, warnen: bool) -> Image: if _camo_bild_cache.has(pfad): var v: Variant = _camo_bild_cache[pfad] return v if v is Image else null if not FileAccess.file_exists(pfad): if warnen: push_warning("GlbMeshStore: Camo-Textur fehlt: %s" % pfad) _camo_bild_cache[pfad] = false return null var img := Image.load_from_file(pfad) if img == null: if warnen: push_warning("GlbMeshStore: Camo-Textur nicht ladbar: %s" % pfad) _camo_bild_cache[pfad] = false return null _camo_bild_cache[pfad] = img return img ## DIE EINE geteilte ImageTexture für eine Multicam-Datei (VRAM-Cache). ## Alle getextureden GLB-Flächen teilen sich genau diese Instanz. static func _camo_tex(tex_pfad: String) -> ImageTexture: if _camo_tex_cache.has(tex_pfad): var v: Variant = _camo_tex_cache[tex_pfad] return v if v is ImageTexture else null var img := _camo_image(tex_pfad, true) if img == null: _camo_tex_cache[tex_pfad] = false return null var tex := ImageTexture.create_from_image(img) _camo_tex_cache[tex_pfad] = tex return tex ## Ersetzt eine GLTF-eigene ImageTexture durch die geteilte Multicam-Instanz, ## WENN der Inhalt (Größe, Format, Pixeldaten) mit einer der Kandidaten- ## Dateien übereinstimmt. null = kein Match → Textur bleibt unangetastet ## (eingebettete/Fremd-Texturen). Urteil je Instanz gecacht. static func _camo_ersetzung_fuer(tex: Texture2D, kandidaten: Array) -> ImageTexture: if not (tex is ImageTexture): return null var iid := tex.get_instance_id() if _camo_ersetzung_cache.has(iid): var v: Variant = _camo_ersetzung_cache[iid] return v if v is ImageTexture else null var img: Image = (tex as ImageTexture).get_image() if img == null: _camo_ersetzung_cache[iid] = false return null var out: ImageTexture = null for pfad in kandidaten: var vergleich := _camo_image(str(pfad), false) if vergleich == null: continue if img.get_width() == vergleich.get_width() \ and img.get_height() == vergleich.get_height() \ and img.get_format() == vergleich.get_format() \ and img.data == vergleich.data: out = _camo_tex(str(pfad)) break _camo_ersetzung_cache[iid] = out if out != null else false return out