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"] ## 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 # Camo-Fix (t_50a07d4b): 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 # (WYSIWYG-Bruch, belegt in t_c8748efc). camo_tex_setzen lädt die Multicam- # Textur DIREKT von der Platte (Image.load_from_file, ohne Import-System) und # setzt sie auf alle Flächen ohne albedo_texture — derselbe Mechanismus wie # im Thumbnail-Pfad (gen_thumbs.gd). Zuordnung je Kategorie exakt wie in # tools/CAMO_TEXTURE_MAPPING.md (Client-Repo, verbindlich): # _1 = Objekte, _2 = Boden (nature: ground*/decal*), _3 = ungenutzt. ## Textur-Praefixe der nature-Unterkategorie, die Bodentextur _2 bekommen. const CAMO_TEX_BODEN_PRAEFIXE := ["ground", "decal"] # 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 (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() ## 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) 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)) 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 if _cache.has(oid): return _cache[oid] if _cache[oid] is Mesh else null 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 ## 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) for pfad in _kandidaten(oid): if FileAccess.file_exists(pfad): return false return true ## 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: # Kategorie + OID aus dem Pfad: .../military_desert//.glb var teile := glb_pfad.split("/") var kategorie := "" var oid := "" for i in teile.size(): if teile[i] == "military_desert" and i + 2 < teile.size(): kategorie = teile[i + 1] oid = teile[i + 2].get_basename() break if kategorie.is_empty(): return var nr := 1 if kategorie == "nature": for p in CAMO_TEX_BODEN_PRAEFIXE: if oid.begins_with(p): nr = 2 break var tex_dir := glb_pfad.get_base_dir().path_join("../textures") # Kategorie-Textur (Fächer OHNE albedo_texture) + Vergleichs-Set: BEIDE # Multicam-Dateien — gebackene GLBs können Misch-Referenzen haben (z. B. # barrel_001: _2 + _1). Die Dedup-Zuordnung geschieht pro Fläche über den # INHALT, nicht per Kategorie-Blindregel. var kategorie_tex_pfad := tex_dir.path_join("desert_multicam_%d.png" % nr) var kandidaten: Array = [kategorie_tex_pfad] var andere_nr := 2 if nr == 1 else 1 var andere_pfad := tex_dir.path_join("desert_multicam_%d.png" % andere_nr) if FileAccess.file_exists(andere_pfad): kandidaten.append(andere_pfad) var kategorie_tex := _camo_tex(kategorie_tex_pfad) # null + push_warning, wenn fehlt 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 var alt := bm.albedo_texture if alt == null: # Fläche ohne Textur (GLTF-Fallback) → Kategorie-Textur # (gen_thumbs-Mechanismus, t_c8748efc). Fehlt die Datei: warnt # _camo_tex (push_warning) statt still weiß zu rendern. if kategorie_tex != null: bm.albedo_texture = kategorie_tex else: push_warning("GlbMeshStore: %s (%s) bleibt untexturiert — Camo-Textur fehlt: %s" % [oid, kategorie, kategorie_tex_pfad]) continue var ersetzung := _camo_ersetzung_fuer(alt, kandidaten) if ersetzung != null: bm.albedo_texture = ersetzung ## Dekodierte Multicam-Bilddatei (RAM-gecacht, einmal je Pfad dekodiert). ## warnen=true: fehlende/nicht ladbare Datei → push_warning (Karten-Pflicht: ## klar warnen statt still grau/weiß zu rendern). null = nicht ladbar. 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