scopedvr-map-editor/addons/modular_builder/scopedvr_export.gd
Hermes (godot-vr) adb585d30e Vereinigung main+feature/builder-export (t_695d57ee): Bauen-Modus + Bibliothek-Export, EIN Vendor-Verzeichnis addons/modular_builder.
feature/builder-export (b3fcb35) in main (e2954a3) gemerged; Doppel-Vendor
aufgelöst: addons/modular_builder_en gelöscht, Export-Kern nach
addons/modular_builder portiert (Pfad-Referenzen angepasst):
- scopedvr_export.gd (Kern: slugify, normalisiere, trimesh_vertices, glb_bytes)
- scopedvr_export_ui.gd (Name-Dialog + Objekt-Pipeline + Thumbnail)
- editor_actions/UI: Export-Modus 'In ScopedVR-Bibliothek (Objekt-Pipeline)'
- tests/test_builder_export.gd (21 Checks, Pitfall ARRAY_INDEX=NULL)
- gen_thumbs: Trimesh-Objekte mit vorhandenem echten Thumbnail überspringen
  (harmlos kombiniert mit dem GLB-Echt-Mesh-Rendering aus t_7cf80244)
- project.godot: Plugin 'res://addons/modular_builder/plugin.cfg'
2026-10-16 16:49:45 +00:00

162 lines
6.4 KiB
GDScript

@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()