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:
Hermes (godot-vr) 2026-10-16 16:49:45 +00:00
commit adb585d30e
9 changed files with 584 additions and 1 deletions

View file

@ -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:

View file

@ -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"

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

View file

@ -0,0 +1 @@
uid://du56x33pwyb4s

View 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

View file

@ -0,0 +1 @@
uid://dx8fw7ovdhua0

View 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

View file

@ -0,0 +1 @@
uid://b1e81c1xd6aur

View file

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