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'
This commit is contained in:
commit
adb585d30e
9 changed files with 584 additions and 1 deletions
|
|
@ -1,6 +1,12 @@
|
||||||
@tool
|
@tool
|
||||||
extends "res://addons/modular_builder/modular_builder_editor_ui.gd"
|
extends "res://addons/modular_builder/modular_builder_editor_ui.gd"
|
||||||
|
|
||||||
|
## ScopedVR-Bibliothek-Export (Karte t_82d2a8dd): dritter Export-Modus
|
||||||
|
## „In ScopedVR-Bibliothek" — gebackenes Mesh + Trimesh-Kollision durch die
|
||||||
|
## Objekt-Pipeline (tools/objekt im Match-Server-Repo), Name-Dialog inklusive.
|
||||||
|
const SCOPEDVR_EXPORT_UI := preload("res://addons/modular_builder/scopedvr_export_ui.gd")
|
||||||
|
var _scopedvr_export: RefCounted = null
|
||||||
|
|
||||||
func _add_piece() -> void:
|
func _add_piece() -> void:
|
||||||
var root := get_editor_interface().get_edited_scene_root()
|
var root := get_editor_interface().get_edited_scene_root()
|
||||||
if root == null or not root is Node3D:
|
if root == null or not root is Node3D:
|
||||||
|
|
@ -61,6 +67,9 @@ func _on_export_pressed() -> void:
|
||||||
if baked_mesh == null or baked_mesh.get_surface_count() == 0:
|
if baked_mesh == null or baked_mesh.get_surface_count() == 0:
|
||||||
status_label.text = "No visible meshes were found to export."
|
status_label.text = "No visible meshes were found to export."
|
||||||
return
|
return
|
||||||
|
if _export_mode_option.get_selected_id() == EXPORT_LIBRARY:
|
||||||
|
_export_to_library(building, baked_mesh)
|
||||||
|
return
|
||||||
if _export_mode_option.get_selected_id() == EXPORT_SCENE_COPY:
|
if _export_mode_option.get_selected_id() == EXPORT_SCENE_COPY:
|
||||||
_add_baked_copy(building, baked_mesh)
|
_add_baked_copy(building, baked_mesh)
|
||||||
return
|
return
|
||||||
|
|
@ -73,6 +82,15 @@ func _on_export_pressed() -> void:
|
||||||
func _bake_building_mesh(building: ModularBuilderPiece) -> ArrayMesh:
|
func _bake_building_mesh(building: ModularBuilderPiece) -> ArrayMesh:
|
||||||
return MESH_BAKER.new().bake(building)
|
return MESH_BAKER.new().bake(building)
|
||||||
|
|
||||||
|
## Export-Modus EXPORT_LIBRARY (Karte t_82d2a8dd): Name-Dialog öffnen, danach
|
||||||
|
## läuft die Objekt-Pipeline (scopedvr_export_ui.gd); der Status landet im
|
||||||
|
## Dock-Statusfeld. Die Instanz bleibt gespeichert, damit der Dialog nicht
|
||||||
|
## vom Garbage Collector gefressen wird, während er offen ist.
|
||||||
|
func _export_to_library(building: ModularBuilderPiece, baked_mesh: ArrayMesh) -> void:
|
||||||
|
_scopedvr_export = SCOPEDVR_EXPORT_UI.new()
|
||||||
|
_scopedvr_export.starten(self, building, baked_mesh, func(text: String) -> void:
|
||||||
|
status_label.text = str(text))
|
||||||
|
|
||||||
func _add_baked_copy(building: ModularBuilderPiece, mesh: ArrayMesh) -> void:
|
func _add_baked_copy(building: ModularBuilderPiece, mesh: ArrayMesh) -> void:
|
||||||
var edited_scene_root := get_editor_interface().get_edited_scene_root()
|
var edited_scene_root := get_editor_interface().get_edited_scene_root()
|
||||||
if edited_scene_root == null or building.get_parent() == null:
|
if edited_scene_root == null or building.get_parent() == null:
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ const TOOL_HATCH := "detail_hatch"
|
||||||
const TOOL_PILLAR := "detail_pillar"
|
const TOOL_PILLAR := "detail_pillar"
|
||||||
const EXPORT_NEW_SCENE := 0
|
const EXPORT_NEW_SCENE := 0
|
||||||
const EXPORT_SCENE_COPY := 1
|
const EXPORT_SCENE_COPY := 1
|
||||||
|
const EXPORT_LIBRARY := 2
|
||||||
const METERS_TO_GODOT_UNITS := 1.0
|
const METERS_TO_GODOT_UNITS := 1.0
|
||||||
const DOOR_DEFAULT_SCALE := 0.8
|
const DOOR_DEFAULT_SCALE := 0.8
|
||||||
const DOOR_DEFAULT_WIDTH_METERS := 0.87 * DOOR_DEFAULT_SCALE
|
const DOOR_DEFAULT_WIDTH_METERS := 0.87 * DOOR_DEFAULT_SCALE
|
||||||
|
|
@ -153,7 +154,8 @@ func _enter_tree() -> void:
|
||||||
_export_mode_option = OptionButton.new()
|
_export_mode_option = OptionButton.new()
|
||||||
_export_mode_option.add_item("New scene", EXPORT_NEW_SCENE)
|
_export_mode_option.add_item("New scene", EXPORT_NEW_SCENE)
|
||||||
_export_mode_option.add_item("Copy in this scene", EXPORT_SCENE_COPY)
|
_export_mode_option.add_item("Copy in this scene", EXPORT_SCENE_COPY)
|
||||||
_export_mode_option.tooltip_text = "Choose whether to save the combined mesh as a new scene or add a copy to the current scene."
|
_export_mode_option.add_item("In ScopedVR-Bibliothek (Objekt-Pipeline)", EXPORT_LIBRARY)
|
||||||
|
_export_mode_option.tooltip_text = "Choose whether to save the combined mesh as a new scene, add a copy to the current scene, or export it into the ScopedVR object library (svmap-ready with trimesh collision)."
|
||||||
_export_row.add_child(_export_mode_option)
|
_export_row.add_child(_export_mode_option)
|
||||||
var export_button := Button.new()
|
var export_button := Button.new()
|
||||||
export_button.text = "Export"
|
export_button.text = "Export"
|
||||||
|
|
|
||||||
162
addons/modular_builder/scopedvr_export.gd
Normal file
162
addons/modular_builder/scopedvr_export.gd
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
@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()
|
||||||
1
addons/modular_builder/scopedvr_export.gd.uid
Normal file
1
addons/modular_builder/scopedvr_export.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://du56x33pwyb4s
|
||||||
250
addons/modular_builder/scopedvr_export_ui.gd
Normal file
250
addons/modular_builder/scopedvr_export_ui.gd
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
@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
|
||||||
1
addons/modular_builder/scopedvr_export_ui.gd.uid
Normal file
1
addons/modular_builder/scopedvr_export_ui.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dx8fw7ovdhua0
|
||||||
141
tests/test_builder_export.gd
Normal file
141
tests/test_builder_export.gd
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Unit-Tests des ScopedVR-Bibliothek-Exports (Karte t_82d2a8dd) — Kern-Logik
|
||||||
|
## aus addons/modular_builder/scopedvr_export.gd, komplett headless:
|
||||||
|
## - slugify (Umlaute, Leerzeichen, Zahlen-Anfang, Länge)
|
||||||
|
## - normalisiere (Fußpunkt y=0, XZ zentriert, Materialien bleiben)
|
||||||
|
## - trimesh_vertices (Dreiecks-Suppe, Dedup, Quantisierung)
|
||||||
|
## - glb_bytes (glTF-Magic, Texturen eingebettet, Dreiecke drin)
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_builder_export.gd
|
||||||
|
|
||||||
|
const KERN := preload("res://addons/modular_builder/scopedvr_export.gd")
|
||||||
|
|
||||||
|
var _failures: Array[String] = []
|
||||||
|
var _checks: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_test_slugify()
|
||||||
|
_test_normalisiere()
|
||||||
|
_test_trimesh()
|
||||||
|
_test_glb()
|
||||||
|
print("--------------------------------------------------")
|
||||||
|
print("CHECKS: %d FAILURES: %d" % [_checks, _failures.size()])
|
||||||
|
for f in _failures:
|
||||||
|
print("FAIL: " + f)
|
||||||
|
quit(0 if _failures.is_empty() else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(cond: bool, label: String) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if cond:
|
||||||
|
print(" ok - " + label)
|
||||||
|
else:
|
||||||
|
_failures.append(label)
|
||||||
|
print(" FAIL - " + label)
|
||||||
|
|
||||||
|
|
||||||
|
func _test_slugify() -> void:
|
||||||
|
print("Testgruppe: slugify")
|
||||||
|
_check(KERN.slugify("Bauernhaus rot") == "bauernhaus_rot", "'Bauernhaus rot' → bauernhaus_rot")
|
||||||
|
_check(KERN.slugify("Häuschen Größe 2") == "haeuschen_groesse_2", "Umlaute werden aufgelöst (ist %s)" % KERN.slugify("Häuschen Größe 2"))
|
||||||
|
_check(KERN.slugify("Straße") == "strasse", "'Straße' → strasse")
|
||||||
|
_check(KERN.slugify("2 Häuser") == "haeuser", "führende Ziffern fliegen (ist %s)" % KERN.slugify("2 Häuser"))
|
||||||
|
_check(KERN.slugify(" schon_gut ") == "schon_gut", "Whitespace/Unterstrich-Ränder weg")
|
||||||
|
_check(KERN.slugify("!!!") == "", "nur Sonderzeichen → leer")
|
||||||
|
_check(KERN.slugify("a".repeat(60)).length() == 48, "max. 48 Zeichen")
|
||||||
|
_check(KERN.slugify("Bauernhaus--rot") == "bauernhaus_rot", "Doppel-Unterstrich kollabieren")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_normalisiere() -> void:
|
||||||
|
print("Testgruppe: normalisiere")
|
||||||
|
var box := BoxMesh.new()
|
||||||
|
box.size = Vector3(2, 1, 4)
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = Color(1, 0, 0)
|
||||||
|
var root_mesh := _mesh_aus_bodies([{"mesh": box, "pos": Vector3(10, 5, -20), "mat": mat}])
|
||||||
|
var norm: Dictionary = KERN.normalisiere(root_mesh)
|
||||||
|
var m: ArrayMesh = norm["mesh"]
|
||||||
|
_check(m != null and m.get_surface_count() == 1, "normalisiere: 1 Oberfläche bleibt")
|
||||||
|
var aabb := m.get_aabb()
|
||||||
|
_check(absf(aabb.position.y) < 0.001, "normalisiere: Boden liegt auf y=0 (min.y=%s)" % aabb.position.y)
|
||||||
|
_check(absf(aabb.get_center().x) < 0.001 and absf(aabb.get_center().z) < 0.001,
|
||||||
|
"normalisiere: XZ zentriert (center=%s)" % aabb.get_center())
|
||||||
|
_check(aabb.size.is_equal_approx(Vector3(2, 1, 4)), "normalisiere: Größe unverändert")
|
||||||
|
var smat := m.surface_get_material(0)
|
||||||
|
_check(smat != null and (smat as StandardMaterial3D).albedo_color == Color(1, 0, 0),
|
||||||
|
"normalisiere: Material bleibt erhalten")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_trimesh() -> void:
|
||||||
|
print("Testgruppe: trimesh_vertices")
|
||||||
|
# Zwei identische Dreiecke + ein echtes: Dedup lässt 3 Vertices-Tripel → 1 übrig.
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
st.add_vertex(Vector3(0, 0, 0)); st.add_vertex(Vector3(1, 0, 0)); st.add_vertex(Vector3(0, 0, 1))
|
||||||
|
st.add_vertex(Vector3(0, 0, 0)); st.add_vertex(Vector3(1, 0, 0)); st.add_vertex(Vector3(0, 0, 1))
|
||||||
|
st.add_vertex(Vector3(0, 1, 0)); st.add_vertex(Vector3(1, 1, 0)); st.add_vertex(Vector3(1, 1, 1))
|
||||||
|
var mesh := st.commit()
|
||||||
|
var faces := KERN.trimesh_vertices(mesh)
|
||||||
|
_check(faces.size() == 6, "Doppel-Dreieck dedupliziert: 9 → 6 Vertices (ist %d)" % faces.size())
|
||||||
|
# Entartetes Dreieck (zwei gleiche Punkte) fliegt raus.
|
||||||
|
var st2 := SurfaceTool.new()
|
||||||
|
st2.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
st2.add_vertex(Vector3(0, 0, 0)); st2.add_vertex(Vector3(0, 0, 0)); st2.add_vertex(Vector3(1, 0, 0))
|
||||||
|
var degeneriert := KERN.trimesh_vertices(st2.commit())
|
||||||
|
_check(degeneriert.is_empty(), "entartetes Dreieck → leer")
|
||||||
|
# Quantisierung: 0,2-mm-Unterschied (weit unter dem 1-mm-Quant) gilt als
|
||||||
|
# identisch — genau auf der Bucket-Grenze (0,5 mm) wäre das nicht garantiert.
|
||||||
|
var st3 := SurfaceTool.new()
|
||||||
|
st3.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
st3.add_vertex(Vector3(0, 0, 0)); st3.add_vertex(Vector3(1, 0, 0)); st3.add_vertex(Vector3(0, 0, 1))
|
||||||
|
st3.add_vertex(Vector3(0, 0.0002, 0)); st3.add_vertex(Vector3(1, 0, 0)); st3.add_vertex(Vector3(0, 0, 1))
|
||||||
|
_check(KERN.trimesh_vertices(st3.commit()).size() == 3, "Quantisierung 1 mm fasst 0,2-mm-Unterschied zusammen")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_glb() -> void:
|
||||||
|
print("Testgruppe: glb_bytes")
|
||||||
|
var box := BoxMesh.new()
|
||||||
|
box.size = Vector3(1, 1, 1)
|
||||||
|
var img := Image.create(8, 8, false, Image.FORMAT_RGB8)
|
||||||
|
img.fill(Color(0.7, 0.3, 0.1))
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_texture = ImageTexture.create_from_image(img)
|
||||||
|
var mesh := _mesh_aus_bodies([{"mesh": box, "pos": Vector3.ZERO, "mat": mat}])
|
||||||
|
var bytes := KERN.glb_bytes(mesh, "testhaus")
|
||||||
|
_check(KERN.glb_gueltig(bytes), "GLB gültig (Magic + Version + JSON-Chunk, %d Bytes)" % bytes.size())
|
||||||
|
var json_len := bytes.decode_u32(12)
|
||||||
|
var parsed: Variant = JSON.parse_string(bytes.slice(20, 20 + json_len).get_string_from_utf8())
|
||||||
|
var gltf: Dictionary = parsed if parsed is Dictionary else {}
|
||||||
|
var tris := 0
|
||||||
|
for m in gltf.get("meshes", []):
|
||||||
|
for p in m.get("primitives", []):
|
||||||
|
var ai: int = int(p.get("indices", -1))
|
||||||
|
if ai >= 0:
|
||||||
|
tris += int(gltf["accessors"][ai].get("count", 0)) / 3
|
||||||
|
_check(tris == 12, "GLB enthält 12 Dreiecke (Box, ist %d)" % tris)
|
||||||
|
_check((gltf.get("images", []) as Array).size() == 1, "Textur ist EINGEBETTET (images=1)")
|
||||||
|
_check(KERN.glb_gueltig("müll".to_utf8_buffer()) == false, "Müll-Bytes werden erkannt")
|
||||||
|
_check(KERN.glb_bytes(null).is_empty(), "null-Mesh → leere Bytes (kein Engine-Error)")
|
||||||
|
|
||||||
|
|
||||||
|
## Hilfsfunktion: baut ein ArrayMesh aus BoxMeshes an Positionen (mit Material).
|
||||||
|
func _mesh_aus_bodies(bodies: Array) -> ArrayMesh:
|
||||||
|
var out := ArrayMesh.new()
|
||||||
|
for b in bodies:
|
||||||
|
var bm: Mesh = b["mesh"]
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.create_from(bm, 0)
|
||||||
|
if b.has("mat"):
|
||||||
|
st.set_material(b["mat"])
|
||||||
|
var tmp := st.commit()
|
||||||
|
var arrays := tmp.surface_get_arrays(0)
|
||||||
|
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||||
|
var off: Vector3 = b.get("pos", Vector3.ZERO)
|
||||||
|
for i in verts.size():
|
||||||
|
verts[i] = verts[i] + off
|
||||||
|
arrays[Mesh.ARRAY_VERTEX] = verts
|
||||||
|
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||||
|
if b.has("mat"):
|
||||||
|
out.surface_set_material(out.get_surface_count() - 1, b["mat"])
|
||||||
|
return out
|
||||||
1
tests/test_builder_export.gd.uid
Normal file
1
tests/test_builder_export.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b1e81c1xd6aur
|
||||||
|
|
@ -81,6 +81,13 @@ func _next() -> void:
|
||||||
var oid := str(ids[idx])
|
var oid := str(ids[idx])
|
||||||
var entry: Dictionary = (manifest["objects"] as Dictionary)[oid]
|
var entry: Dictionary = (manifest["objects"] as Dictionary)[oid]
|
||||||
var col: Dictionary = entry.get("collision", {})
|
var col: Dictionary = entry.get("collision", {})
|
||||||
|
# Trimesh-Objekte (Modular-Builder-Export, t_82d2a8dd): das Thumbnail wurde
|
||||||
|
# beim Export aus dem ECHTEN Mesh gerendert (der Platzhalter hier wäre nur
|
||||||
|
# eine Box) — existiert die Datei schon, wird sie nicht überschrieben.
|
||||||
|
if str(col.get("shape", "box")) == "trimesh" and FileAccess.file_exists(out_dir + "/" + oid + ".png"):
|
||||||
|
print(" thumb: %s übersprungen (echtes Thumbnail vom Export)" % oid)
|
||||||
|
_next()
|
||||||
|
return
|
||||||
var mesh := _mesh_fuer(oid, col)
|
var mesh := _mesh_fuer(oid, col)
|
||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
# Kamera diagonal aufs Objekt, passend zur Mesh-Größe (Kiste nah, Turm weit).
|
# Kamera diagonal aufs Objekt, passend zur Mesh-Größe (Kiste nah, Turm weit).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue