diff --git a/addons/modular_builder/modular_builder_editor_actions.gd b/addons/modular_builder/modular_builder_editor_actions.gd index 60faa34..adfb77a 100644 --- a/addons/modular_builder/modular_builder_editor_actions.gd +++ b/addons/modular_builder/modular_builder_editor_actions.gd @@ -1,6 +1,12 @@ @tool 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: var root := get_editor_interface().get_edited_scene_root() 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: status_label.text = "No visible meshes were found to export." 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: _add_baked_copy(building, baked_mesh) return @@ -73,6 +82,15 @@ func _on_export_pressed() -> void: func _bake_building_mesh(building: ModularBuilderPiece) -> ArrayMesh: 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: var edited_scene_root := get_editor_interface().get_edited_scene_root() if edited_scene_root == null or building.get_parent() == null: diff --git a/addons/modular_builder/modular_builder_editor_ui.gd b/addons/modular_builder/modular_builder_editor_ui.gd index 7e012b6..aba23a2 100644 --- a/addons/modular_builder/modular_builder_editor_ui.gd +++ b/addons/modular_builder/modular_builder_editor_ui.gd @@ -31,6 +31,7 @@ const TOOL_HATCH := "detail_hatch" const TOOL_PILLAR := "detail_pillar" const EXPORT_NEW_SCENE := 0 const EXPORT_SCENE_COPY := 1 +const EXPORT_LIBRARY := 2 const METERS_TO_GODOT_UNITS := 1.0 const DOOR_DEFAULT_SCALE := 0.8 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.add_item("New scene", EXPORT_NEW_SCENE) _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) var export_button := Button.new() export_button.text = "Export" diff --git a/addons/modular_builder/scopedvr_export.gd b/addons/modular_builder/scopedvr_export.gd new file mode 100644 index 0000000..fcbcfa3 --- /dev/null +++ b/addons/modular_builder/scopedvr_export.gd @@ -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() diff --git a/addons/modular_builder/scopedvr_export.gd.uid b/addons/modular_builder/scopedvr_export.gd.uid new file mode 100644 index 0000000..f55032d --- /dev/null +++ b/addons/modular_builder/scopedvr_export.gd.uid @@ -0,0 +1 @@ +uid://du56x33pwyb4s diff --git a/addons/modular_builder/scopedvr_export_ui.gd b/addons/modular_builder/scopedvr_export_ui.gd new file mode 100644 index 0000000..b964793 --- /dev/null +++ b/addons/modular_builder/scopedvr_export_ui.gd @@ -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 diff --git a/addons/modular_builder/scopedvr_export_ui.gd.uid b/addons/modular_builder/scopedvr_export_ui.gd.uid new file mode 100644 index 0000000..47cd081 --- /dev/null +++ b/addons/modular_builder/scopedvr_export_ui.gd.uid @@ -0,0 +1 @@ +uid://dx8fw7ovdhua0 diff --git a/tests/test_builder_export.gd b/tests/test_builder_export.gd new file mode 100644 index 0000000..986fcef --- /dev/null +++ b/tests/test_builder_export.gd @@ -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 diff --git a/tests/test_builder_export.gd.uid b/tests/test_builder_export.gd.uid new file mode 100644 index 0000000..fe98dab --- /dev/null +++ b/tests/test_builder_export.gd.uid @@ -0,0 +1 @@ +uid://b1e81c1xd6aur diff --git a/tools/gen_thumbs.gd b/tools/gen_thumbs.gd index 2580eb0..cb86b18 100644 --- a/tools/gen_thumbs.gd +++ b/tools/gen_thumbs.gd @@ -81,6 +81,13 @@ func _next() -> void: var oid := str(ids[idx]) var entry: Dictionary = (manifest["objects"] as Dictionary)[oid] 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) mi.mesh = mesh # Kamera diagonal aufs Objekt, passend zur Mesh-Größe (Kiste nah, Turm weit).