@tool extends RefCounted ## ScopedVR-Bibliothek-Export für den Modular Builder (Karte t_82d2a8dd). ## ## Kern-Logik des „Struktur exportieren → ScopedVR-Bibliothek"-Flows — bewusst ## ALLES statisch und ohne Editor-UI, damit tests/test_builder_export.gd sie ## headless prüfen kann. Der UI-Ablauf (Name-Dialog, Pipeline-Aufrufe, Status) ## lebt in scopedvr_export_ui.gd; der Editor-Button hängt in ## modular_builder_editor_actions.gd (_on_export_pressed → EXPORT_LIBRARY). ## ## Pipeline (wie Fass, docs/object_pipeline.md): ## 1. Mesh normalisieren (Fußpunkt = y 0, XZ zentriert) ## 2. GLB schreiben (GLTFDocument, Texturen eingebettet) ## 3. Trimesh-Vertices (Dreiecks-Suppe, dedupliziert, 1 mm quantisiert) ## 4. tools/objekt mesh → registry (trimesh, --budget) → client → editor ## Die Registry bekommt "shape": "trimesh" + "vertices" + "size" (AABB) — ## der Match-Server-Lader baut daraus ein ConcavePolygonShape3D (t_c949ebf2). const QUANT := 0.001 # 1 mm — Kollision darf ~1 mm vom visuellen Mesh abweichen ## Name → Objekt-ID: kleinbuchstaben, ä/ö/ü/ß aufgelöst, alles Ungültige → '_', ## führende Ziffern weg, max. 48 Zeichen. Rückgabe "" = Name unbrauchbar. static func slugify(name: String) -> String: var s := name.strip_edges().to_lower() s = s.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") var out := "" for c in s: var ok := (c >= "a" and c <= "z") or (c >= "0" and c <= "9") or c == "_" out += c if ok else "_" # Führende Unterstriche/Ziffern abstreifen (IDs müssen mit Buchstabe beginnen). while out.length() > 0 and not (out[0] >= "a" and out[0] <= "z"): out = out.substr(1) while out.contains("__"): out = out.replace("__", "_") out = out.strip_edges() while out.ends_with("_"): out = out.substr(0, out.length() - 1) if out.length() > 48: out = out.substr(0, 48) if out.length() == 0 or not (out[0] >= "a" and out[0] <= "z"): return "" return out ## Mesh normalisieren: Kopie, in der der Boden auf y = 0 liegt und das Haus ## um seinen XZ-Mittelpunkt steht — der svmap-Fußpunkt-Vertrag (pos = Boden). ## Rückgabe {mesh: ArrayMesh, offset: Vector3 (angewandter Versatz), size: Vector3 (AABB)}. ## null im Ergebnis-Mesh = nichts zu holen (leeres Mesh). static func normalisiere(mesh: ArrayMesh) -> Dictionary: var aabb := mesh.get_aabb() if aabb.size.length() <= 0.0: return {"mesh": null, "offset": Vector3.ZERO, "size": Vector3.ZERO} 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 := 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 src_mat := mesh.surface_get_material(s) if src_mat != null: out.surface_set_material(out.get_surface_count() - 1, src_mat) return {"mesh": out, "offset": offset, "size": aabb.size} ## Trimesh-Vertices (Dreiecks-Suppe, Loader-Vertrag svmap_loader._trimesh_faces): ## je 3 Vertices ein Dreieck, aus ALLEN Oberflächen, exakte Doppel-Dreiecke ## (quantisiert) fliegen raus — Decken/Wände aus aneinander liegenden Teilen ## dürfen die Kollisions-Suppe nicht unnötig aufblähen. static func trimesh_vertices(mesh: ArrayMesh) -> PackedVector3Array: var seen := {} var out := PackedVector3Array() for s in mesh.get_surface_count(): var arrays := 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] # ARRAY_INDEX ist null (nicht PackedInt32Array), wenn das Mesh nicht # indiziert ist — der direkte Cast bricht sonst die ganze Funktion ab. var idx := PackedInt32Array() if arrays[Mesh.ARRAY_INDEX] is PackedInt32Array: idx = arrays[Mesh.ARRAY_INDEX] var tri_count := 0 if not idx.is_empty(): tri_count = idx.size() / 3 else: tri_count = verts.size() / 3 for t in tri_count: var a: Vector3 var b: Vector3 var c: Vector3 if not idx.is_empty(): a = verts[idx[t * 3]] b = verts[idx[t * 3 + 1]] c = verts[idx[t * 3 + 2]] else: a = verts[t * 3] b = verts[t * 3 + 1] c = verts[t * 3 + 2] var key := _dreieck_schluessel(a, b, c) if key.is_empty(): continue # Entartet (Fläche 0 in min. einer Komponente). if seen.has(key): continue seen[key] = true out.append_array(PackedVector3Array([a, b, c])) return out ## Sortierter Quantisierungs-Schlüssel eines Dreiecks (unabhängig von der ## Vertex-Reihenfolge) — "" bei entarteten Dreiecken (zwei identische Punkte). static func _dreieck_schluessel(a: Vector3, b: Vector3, c: Vector3) -> String: var pts := [_quant(a), _quant(b), _quant(c)] if pts[0] == pts[1] or pts[1] == pts[2] or pts[0] == pts[2]: return "" pts.sort() return "%s|%s|%s" % [pts[0], pts[1], pts[2]] static func _quant(v: Vector3) -> String: return "%d,%d,%d" % [roundi(v.x / QUANT), roundi(v.y / QUANT), roundi(v.z / QUANT)] ## Mesh als GLB-Bytes (Texturen eingebettet, image/png) — über GLTFDocument, ## funktioniert headless und im Editor (Probe: tests/test_builder_export.gd). ## Leere Bytes = Export fehlgeschlagen. static func glb_bytes(mesh: ArrayMesh, name_hint := "Struktur") -> PackedByteArray: if mesh == null: return PackedByteArray() # Kein Mesh → Export fehlschlagen, kein Engine-Error. var root := Node3D.new() root.name = name_hint var mi := MeshInstance3D.new() mi.name = "BakedMesh" mi.mesh = mesh root.add_child(mi) var state := GLTFState.new() var doc := GLTFDocument.new() var err := doc.append_from_scene(root, state) if err != OK: return PackedByteArray() var bytes := doc.generate_buffer(state) root.free() return bytes ## GLB-Bytes grob prüfen (glTF-Magic + Version + JSON-Chunk) — reicht für die ## Selbst-Verifikation VOR dem Pipeline-Aufruf; die Dreiecks-Zählung macht ## tools/objekt eh noch mal selbst. static func glb_gueltig(bytes: PackedByteArray) -> bool: if bytes.size() < 20: return false if bytes.decode_u32(0) != 0x46546C67 or bytes.decode_u32(4) != 2: return false if bytes.decode_u32(16) != 0x4E4F534A: return false return 20 + bytes.decode_u32(12) <= bytes.size()