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'
162 lines
6.4 KiB
GDScript
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()
|