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'
250 lines
9 KiB
GDScript
250 lines
9 KiB
GDScript
@tool
|
||
extends RefCounted
|
||
## UI-Ablauf des ScopedVR-Bibliothek-Exports (Karte t_82d2a8dd): Name-Dialog →
|
||
## Mesh normalisieren → GLB + Trimesh-Vertices → tools/objekt (Match-Server-Repo)
|
||
## → Thumbnail → Status.
|
||
##
|
||
## Bewusst getrennt von scopedvr_export.gd (Kern, statisch/testbar) — dieses
|
||
## Skript braucht die Editor-Umgebung (Dialoge, OS.execute, Rendering).
|
||
## Deutsch für Sly: alle Status-/Fehlermeldungen auf Deutsch.
|
||
|
||
const KERN := preload("res://addons/modular_builder/scopedvr_export.gd")
|
||
const BUDGET := 20000 # Export-Strukturen (match-server BUILDER_BUDGET)
|
||
const THUMB_SIZE := 128
|
||
|
||
var _plugin: EditorPlugin
|
||
var _status: Callable
|
||
var _building: Node3D
|
||
var _mesh: ArrayMesh
|
||
var _dialog: AcceptDialog
|
||
var _name_edit: LineEdit
|
||
|
||
|
||
## Startet den Flow. building = gewählte Struktur, mesh = gebackenes Mesh
|
||
## (aus modular_mesh_baker), status = Callable(label: String) für das Dock-Statusfeld.
|
||
func starten(plugin: EditorPlugin, building: Node3D, mesh: ArrayMesh, status: Callable) -> void:
|
||
_plugin = plugin
|
||
_building = building
|
||
_mesh = mesh
|
||
_status = status
|
||
if _dialog != null:
|
||
_dialog.queue_free()
|
||
_dialog = AcceptDialog.new()
|
||
_dialog.title = "Struktur exportieren — ScopedVR-Bibliothek"
|
||
_dialog.ok_button_text = "Exportieren"
|
||
var vbox := VBoxContainer.new()
|
||
vbox.custom_minimum_size = Vector2(380, 0)
|
||
var hinweis := Label.new()
|
||
hinweis.text = "Name der Struktur (wird zur Objekt-ID: kleine Buchstaben,\nZahlen, Unterstrich — z. B. „Bauernhaus Rot\" → haus_bauernhaus_rot... \nrichtig: „Bauernhaus rot\" → bauernhaus_rot).\n\nDer Export durchläuft die Objekt-Pipeline (Mesh, Registry,\nClient, Editor) — der Client-Import dauert einen Moment."
|
||
hinweis.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
vbox.add_child(hinweis)
|
||
_name_edit = LineEdit.new()
|
||
_name_edit.text = str(building.name)
|
||
vbox.add_child(_name_edit)
|
||
_dialog.add_child(vbox)
|
||
_dialog.confirmed.connect(_on_name_ok)
|
||
plugin.add_child(_dialog)
|
||
_name_edit.text_submitted.connect(func(_t: String) -> void:
|
||
_dialog.hide()
|
||
_on_name_ok())
|
||
_name_edit.call_deferred("grab_focus")
|
||
_dialog.popup_centered()
|
||
|
||
|
||
func _on_name_ok() -> void:
|
||
var name_roh := _name_edit.text
|
||
var oid := KERN.slugify(name_roh)
|
||
if oid.is_empty():
|
||
_status.call("FEHLER: Aus dem Namen „%s\" wird keine gültige Objekt-ID — bitte Buchstaben verwenden." % name_roh)
|
||
return
|
||
_status.call("Export läuft: „%s\" → %s (Pipeline: Mesh → Registry → Client → Editor) ..." % [name_roh, oid])
|
||
# Einen Frame abwarten, damit der Status sichtbar wird, bevor OS.execute blockiert.
|
||
await _plugin.get_tree().process_frame
|
||
await _export(oid, name_roh)
|
||
|
||
|
||
func _export(oid: String, name_roh: String) -> void:
|
||
var server_dir := _match_server_dir()
|
||
if server_dir.is_empty():
|
||
_status.call("FEHLER: Match-Server-Repo nicht gefunden — erwartet ../scopedvr-match-server neben dem Map-Editor (oder SCOPEDVR_MATCH_SERVER_DIR).")
|
||
return
|
||
var objekt_tool := server_dir + "/tools/objekt"
|
||
|
||
# 1) Normalisieren (Fußpunkt y=0, XZ zentriert).
|
||
var norm: Dictionary = KERN.normalisiere(_mesh)
|
||
var mesh: ArrayMesh = norm.get("mesh")
|
||
if mesh == null:
|
||
_status.call("FEHLER: Das gebackene Mesh ist leer — nichts zu exportieren.")
|
||
return
|
||
var size: Vector3 = norm["size"]
|
||
var faces := KERN.trimesh_vertices(mesh)
|
||
if faces.is_empty():
|
||
_status.call("FEHLER: Keine Kollisions-Dreiecke im gebackenen Mesh.")
|
||
return
|
||
|
||
# 2) GLB + Vertices temporär schreiben.
|
||
var tmp_dir := _tmp_dir()
|
||
var glb_path := tmp_dir + "/scopedvr_export_%s.glb" % oid
|
||
var verts_path := tmp_dir + "/scopedvr_export_%s.json" % oid
|
||
var glb := KERN.glb_bytes(mesh, oid)
|
||
if glb.is_empty() or not KERN.glb_gueltig(glb):
|
||
_status.call("FEHLER: GLB-Export fehlgeschlagen (GLTFDocument).")
|
||
return
|
||
var gf := FileAccess.open(glb_path, FileAccess.WRITE)
|
||
if gf == null:
|
||
_status.call("FEHLER: Konnte %s nicht schreiben." % glb_path)
|
||
return
|
||
gf.store_buffer(glb)
|
||
gf.close()
|
||
var vertices_liste: Array = []
|
||
for v in faces:
|
||
vertices_liste.append([_r(v.x), _r(v.y), _r(v.z)])
|
||
var verts_json := JSON.stringify({
|
||
"vertices": vertices_liste,
|
||
"size": [_r(size.x), _r(size.y), _r(size.z)],
|
||
})
|
||
var vf := FileAccess.open(verts_path, FileAccess.WRITE)
|
||
if vf == null:
|
||
_status.call("FEHLER: Konnte %s nicht schreiben." % verts_path)
|
||
return
|
||
vf.store_string(verts_json)
|
||
vf.close()
|
||
|
||
# 3) Pipeline-Schritte 1–2 (Mesh + Registry). Fehler = sofort raus, klare Meldung.
|
||
var schritt1 := _lauf(objekt_tool, ["mesh", oid, glb_path, "--budget", str(BUDGET)])
|
||
if schritt1 != "":
|
||
_status.call(schritt1)
|
||
return
|
||
var schritt2 := _lauf(objekt_tool, ["registry", oid, name_roh.strip_edges(), "trimesh", "--vertices", verts_path, "--budget", str(BUDGET)])
|
||
if schritt2 != "":
|
||
_status.call(schritt2)
|
||
return
|
||
|
||
# 4) Client-Sync (kopiert GLB + Registry in den Client, Import-Probe).
|
||
_status.call("Export läuft: Client-Import (dauert einen Moment) ...")
|
||
var schritt3 := _lauf(objekt_tool, ["client", oid])
|
||
if schritt3 != "":
|
||
_status.call(schritt3)
|
||
return
|
||
|
||
# 5) Thumbnail AUS DEM ECHTEN MESH rendern (bevor der Editor-Sync kommt —
|
||
# gen_thumbs überspringt Trimesh-Objekte mit vorhandener Datei).
|
||
var editor_dir := _editor_dir()
|
||
if not editor_dir.is_empty():
|
||
var img := await _thumbnail_bilden(mesh)
|
||
if img != null:
|
||
DirAccess.make_dir_recursive_absolute(editor_dir + "/library/thumbs")
|
||
img.save_png(editor_dir + "/library/thumbs/" + oid + ".png")
|
||
|
||
# 6) Editor-Sync (Registry-Kopie; vorhandene Thumbnails bleiben stehen).
|
||
var schritt4 := _lauf(objekt_tool, ["editor", oid])
|
||
if schritt4 != "":
|
||
_status.call(schritt4)
|
||
return
|
||
|
||
var vis_tris := 0
|
||
var tris := int(faces.size() / 3)
|
||
for s in mesh.get_surface_count():
|
||
var arrays := mesh.surface_get_arrays(s)
|
||
if arrays.is_empty():
|
||
continue
|
||
# ARRAY_INDEX ist null bei nicht-indizierten Oberflächen (kein Cast!).
|
||
var idx := PackedInt32Array()
|
||
if arrays[Mesh.ARRAY_INDEX] is PackedInt32Array:
|
||
idx = arrays[Mesh.ARRAY_INDEX]
|
||
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||
vis_tris += (idx.size() / 3) if not idx.is_empty() else (verts.size() / 3)
|
||
_status.call("OK: „%s\" als '%s' exportiert (poly %d, Trimesh %d Dreiecke, Budget %d). Objekt liegt in Bibliothek + Client — Einchecken nicht vergessen (git add in 3 Repos)." % [name_roh, oid, vis_tris, tris, BUDGET])
|
||
_aufraeumen([glb_path, verts_path])
|
||
|
||
|
||
func _aufraeumen(pfade: Array) -> void:
|
||
for p in pfade:
|
||
if FileAccess.file_exists(p):
|
||
DirAccess.remove_absolute(p)
|
||
|
||
|
||
func _lauf(objekt_tool: String, args: PackedStringArray) -> String:
|
||
var out := []
|
||
var code: int = OS.execute(objekt_tool, args, out, true)
|
||
var text := "\n".join(out)
|
||
if code == 0:
|
||
return ""
|
||
# Die FEHLER-Zeile des Werkzeugs durchreichen (letzte gewinnt).
|
||
var fehler := "Pipeline fehlgeschlagen (Exit %d)." % code
|
||
for zeile in text.split("\n"):
|
||
if str(zeile).begins_with("FEHLER: "):
|
||
fehler = str(zeile)
|
||
elif str(zeile).begins_with("FEHLER:"):
|
||
fehler = str(zeile)
|
||
return fehler
|
||
|
||
|
||
func _match_server_dir() -> String:
|
||
var env := OS.get_environment("SCOPEDVR_MATCH_SERVER_DIR")
|
||
if not env.is_empty() and DirAccess.dir_exists_absolute(env):
|
||
return env.rstrip("/")
|
||
var root := ProjectSettings.globalize_path("res://").rstrip("/")
|
||
var cand := root.path_join("../scopedvr-match-server")
|
||
if DirAccess.dir_exists_absolute(cand):
|
||
return cand
|
||
return ""
|
||
|
||
|
||
func _editor_dir() -> String:
|
||
var env := OS.get_environment("SCOPEDVR_EDITOR_DIR")
|
||
if not env.is_empty() and DirAccess.dir_exists_absolute(env):
|
||
return env.rstrip("/")
|
||
return ProjectSettings.globalize_path("res://").rstrip("/")
|
||
|
||
|
||
func _tmp_dir() -> String:
|
||
var t := OS.get_environment("TMPDIR")
|
||
if t.is_empty():
|
||
t = "/tmp"
|
||
return t.rstrip("/")
|
||
|
||
|
||
static func _r(v: float) -> float:
|
||
return snappedf(v, 0.0001)
|
||
|
||
|
||
## Thumbnail aus dem Mesh rendern (Editor-Rendering vorausgesetzt) —
|
||
## gleicher Stil wie tools/gen_thumbs.gd (128 px, dunkler Hintergrund).
|
||
func _thumbnail_bilden(mesh: ArrayMesh) -> Image:
|
||
var vp := SubViewport.new()
|
||
vp.size = Vector2i(THUMB_SIZE, THUMB_SIZE)
|
||
vp.own_world_3d = true
|
||
vp.render_target_update_mode = SubViewport.UPDATE_ONCE
|
||
_plugin.add_child(vp)
|
||
var env := Environment.new()
|
||
env.background_mode = Environment.BG_COLOR
|
||
env.background_color = Color(0.16, 0.18, 0.21)
|
||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||
env.ambient_light_color = Color(1, 1, 1)
|
||
env.ambient_light_energy = 0.7
|
||
var we := WorldEnvironment.new()
|
||
we.environment = env
|
||
vp.add_child(we)
|
||
var sun := DirectionalLight3D.new()
|
||
sun.rotation_degrees = Vector3(-55.0, 35.0, 0.0)
|
||
sun.light_energy = 1.1
|
||
vp.add_child(sun)
|
||
var mi := MeshInstance3D.new()
|
||
mi.mesh = mesh
|
||
vp.add_child(mi)
|
||
var cam := Camera3D.new()
|
||
cam.current = true
|
||
vp.add_child(cam)
|
||
var aabb := mesh.get_aabb()
|
||
var center := aabb.get_center()
|
||
var radius := aabb.size.length() * 0.5
|
||
var dir := Vector3(1.0, 0.65, 1.0).normalized()
|
||
cam.position = center + dir * (radius * 2.3 + 0.3)
|
||
cam.look_at(center, Vector3.UP)
|
||
# Ein Frame Rendering abwarten, dann auslesen und aufräumen.
|
||
await _plugin.get_tree().process_frame
|
||
await _plugin.get_tree().process_frame
|
||
var img := vp.get_texture().get_image()
|
||
vp.queue_free()
|
||
return img
|