|
|
|
@@ -0,0 +1,200 @@
|
|
|
|
|
"""Stamm-UCX fuer den spielfertigen Stand-Baum.
|
|
|
|
|
|
|
|
|
|
Die Stand-Baeume brauchen Kollision. Gefordert ist EIN konvexer Stamm-Zylinder
|
|
|
|
|
vom Fuss bis zum Kronenansatz - KEINE Kronen-Huelle: gegen die liefe der
|
|
|
|
|
Spieler, bevor er den Baum ueberhaupt beruehrt.
|
|
|
|
|
|
|
|
|
|
Die Namens- und Hierarchie-Konvention ist nicht geraten, sondern vom
|
|
|
|
|
Fels-Export abgeschaut, der in der Spiel-Pipeline bereits abgenommen ist:
|
|
|
|
|
`UCX_<LODGroup-Name>`, und zwar als GESCHWISTER der LodGroup, nicht als Kind -
|
|
|
|
|
Unreal liest jedes Kind der Gruppe als LOD-Stufe.
|
|
|
|
|
|
|
|
|
|
Gemessen wird im reimportierten FBX.
|
|
|
|
|
|
|
|
|
|
Aufruf: blender --background --factory-startup --python tests/test_stamm_ucx.py
|
|
|
|
|
"""
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import math
|
|
|
|
|
import tempfile
|
|
|
|
|
import importlib.util
|
|
|
|
|
|
|
|
|
|
import bpy
|
|
|
|
|
|
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
|
|
|
"tg", os.path.join(ROOT, "stylized_tree_generator.py"))
|
|
|
|
|
tg = importlib.util.module_from_spec(spec)
|
|
|
|
|
sys.modules["tg"] = tg
|
|
|
|
|
spec.loader.exec_module(tg)
|
|
|
|
|
tg.register()
|
|
|
|
|
|
|
|
|
|
fails = []
|
|
|
|
|
OUT = tempfile.mkdtemp()
|
|
|
|
|
|
|
|
|
|
for o in list(bpy.data.objects):
|
|
|
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
|
|
|
|
|
|
|
|
me = bpy.data.meshes.new("Leaf_Card")
|
|
|
|
|
me.from_pydata([(-0.5, 0, 0), (0, 0, 0.7), (0.5, 0, 0), (0, 0, -0.1)],
|
|
|
|
|
[], [(0, 1, 2), (0, 2, 3)])
|
|
|
|
|
me.update()
|
|
|
|
|
card = bpy.data.objects.new("Leaf_Card", me)
|
|
|
|
|
bpy.context.scene.collection.objects.link(card)
|
|
|
|
|
|
|
|
|
|
s = bpy.context.scene.tree_gen_settings
|
|
|
|
|
s.apply_modifier = True
|
|
|
|
|
s.leaf_card = card
|
|
|
|
|
s.use_growth = False
|
|
|
|
|
s.stand_ucx = True
|
|
|
|
|
|
|
|
|
|
# Presets mit verschiedenen Stamm-Formen: gerade, krumm (Bend 1.5), ohne Aeste
|
|
|
|
|
# am Setzling, und der Obstbaum als Produktionsfall.
|
|
|
|
|
FAELLE = (("eiche", True), ("toter_baum", True), ("obstbaum", True),
|
|
|
|
|
("eiche", False))
|
|
|
|
|
|
|
|
|
|
# Erst ALLE Faelle exportieren, dann reimportieren. `read_factory_settings`
|
|
|
|
|
# raeumt die Szene komplett aus - Blatt-Card und Einstellungen inklusive -,
|
|
|
|
|
# ein Reimport mitten in der Schleife killt also den naechsten Durchlauf.
|
|
|
|
|
ergebnisse = []
|
|
|
|
|
for preset, mit_lods in FAELLE:
|
|
|
|
|
for o in list(bpy.context.scene.objects):
|
|
|
|
|
if o is not card:
|
|
|
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
|
|
|
ziel_dir = os.path.join(OUT, "%s_%d" % (preset, int(mit_lods)))
|
|
|
|
|
os.makedirs(ziel_dir, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
s.preset = preset
|
|
|
|
|
s.art_name = preset.capitalize()
|
|
|
|
|
s.variant = 1
|
|
|
|
|
s.seed = 1
|
|
|
|
|
s.generate_lods = mit_lods
|
|
|
|
|
s.export_dir = ziel_dir
|
|
|
|
|
bpy.ops.object.treegen_create()
|
|
|
|
|
baum = [o for o in bpy.context.scene.objects
|
|
|
|
|
if o.type == 'MESH' and o is not card
|
|
|
|
|
and not o.name.endswith(("_Leaf", "_Frucht"))][0]
|
|
|
|
|
# BEWUSST nicht am Ursprung - eine Huelle, die die Weltmatrix mitschleppt,
|
|
|
|
|
# faellt bei (0,0,0) nicht auf.
|
|
|
|
|
baum.location = (9.0, -5.0, 0.0)
|
|
|
|
|
for o in bpy.context.scene.objects:
|
|
|
|
|
o.select_set(o is baum)
|
|
|
|
|
bpy.context.view_layer.objects.active = baum
|
|
|
|
|
bpy.ops.object.treegen_leaves()
|
|
|
|
|
bl = bpy.data.objects.get(baum.name + "_Leaf")
|
|
|
|
|
if bl is not None:
|
|
|
|
|
bl.location = baum.location
|
|
|
|
|
|
|
|
|
|
# Kronenansatz und Stammradius am Quellbaum, als Sollwerte.
|
|
|
|
|
dg = bpy.context.evaluated_depsgraph_get()
|
|
|
|
|
ev = baum.evaluated_get(dg)
|
|
|
|
|
m = ev.to_mesh()
|
|
|
|
|
attr = m.attributes.get("ast_id")
|
|
|
|
|
vs = [v.co.copy() for v in m.vertices]
|
|
|
|
|
aid = [attr.data[i].value for i in range(len(vs))] if attr else [0] * len(vs)
|
|
|
|
|
ev.to_mesh_clear()
|
|
|
|
|
z0 = min(v.z for v in vs)
|
|
|
|
|
astz = [v.z for v, i in zip(vs, aid) if i > 0]
|
|
|
|
|
ansatz = (min(astz) - z0) if astz else (max(v.z for v in vs) - z0)
|
|
|
|
|
stamm_r = max((math.hypot(v.x, v.y) for v, i in zip(vs, aid)
|
|
|
|
|
if i == 0 and v.z - z0 <= ansatz), default=0.0)
|
|
|
|
|
|
|
|
|
|
for o in bpy.context.scene.objects:
|
|
|
|
|
o.select_set(o is baum)
|
|
|
|
|
bpy.context.view_layer.objects.active = baum
|
|
|
|
|
bpy.ops.treegen.export_game()
|
|
|
|
|
|
|
|
|
|
dateien = sorted(f for f in os.listdir(ziel_dir)
|
|
|
|
|
if f.lower().endswith(".fbx"))
|
|
|
|
|
if not dateien:
|
|
|
|
|
fails.append("%s: kein FBX geschrieben" % preset)
|
|
|
|
|
continue
|
|
|
|
|
ergebnisse.append((preset, mit_lods, os.path.join(ziel_dir, dateien[0]),
|
|
|
|
|
stamm_r, ansatz, max(v.z for v in vs) - z0,
|
|
|
|
|
s.stand_ucx_verts))
|
|
|
|
|
|
|
|
|
|
print("%-12s %-5s %6s %8s %8s %8s %8s"
|
|
|
|
|
% ("Preset", "LODs", "Verts", "UCX-R", "Stamm-R", "UCX-Z", "Ansatz-Z"))
|
|
|
|
|
|
|
|
|
|
for preset, mit_lods, pfad, stamm_r, ansatz, blender_h, max_verts in ergebnisse:
|
|
|
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
|
bpy.ops.import_scene.fbx(filepath=pfad)
|
|
|
|
|
|
|
|
|
|
ucx = [o for o in bpy.context.scene.objects
|
|
|
|
|
if o.type == 'MESH' and o.name.startswith("UCX_")]
|
|
|
|
|
rest = [o for o in bpy.context.scene.objects
|
|
|
|
|
if o.type == 'MESH' and not o.name.startswith("UCX_")]
|
|
|
|
|
gruppe = [o for o in bpy.context.scene.objects if o.type == 'EMPTY']
|
|
|
|
|
|
|
|
|
|
if len(ucx) != 1:
|
|
|
|
|
fails.append("%s (LODs %s): %d UCX-Meshes im FBX, erwartet genau eines"
|
|
|
|
|
% (preset, mit_lods, len(ucx)))
|
|
|
|
|
continue
|
|
|
|
|
u = ucx[0]
|
|
|
|
|
|
|
|
|
|
# Name: exakt die Konvention des Fels-Exports.
|
|
|
|
|
erwartet = ("UCX_" + gruppe[0].name) if gruppe else ("UCX_" + rest[0].name)
|
|
|
|
|
if u.name != erwartet:
|
|
|
|
|
fails.append("%s: Huelle heisst '%s', erwartet '%s' - Unreal ordnet "
|
|
|
|
|
"die Kollision ueber den Namen zu"
|
|
|
|
|
% (preset, u.name, erwartet))
|
|
|
|
|
|
|
|
|
|
# Hierarchie: NICHT unter der LodGroup, sonst waere sie eine LOD-Stufe.
|
|
|
|
|
if u.parent is not None:
|
|
|
|
|
fails.append("%s: Huelle haengt unter '%s' - Unreal liest jedes Kind "
|
|
|
|
|
"der LodGroup als LOD-Stufe" % (preset, u.parent.name))
|
|
|
|
|
|
|
|
|
|
co = [u.matrix_world @ v.co for v in u.data.vertices]
|
|
|
|
|
u_r = max(math.hypot(c.x, c.y) for c in co)
|
|
|
|
|
u_z0 = min(c.z for c in co)
|
|
|
|
|
u_z1 = max(c.z for c in co)
|
|
|
|
|
n = len(u.data.vertices)
|
|
|
|
|
|
|
|
|
|
# Skala aus dem FBX zurueckrechnen, damit die Vergleiche unabhaengig von
|
|
|
|
|
# FBX_SCALE_ALL sind. NUR ueber die RINDE (Material-Slot 0): der erste
|
|
|
|
|
# Anlauf nahm die Gesamthoehe inklusive Blatt-Cards, und die ragen ueber die
|
|
|
|
|
# Rinde hinaus - die Skala kam dadurch zu gross heraus und die Huelle sah
|
|
|
|
|
# zu klein aus. Ein Massstab, der etwas anderes misst als die Referenz,
|
|
|
|
|
# verschiebt jede Aussage.
|
|
|
|
|
lod0 = max(rest, key=lambda o: len(o.data.vertices))
|
|
|
|
|
rinde = [lod0.matrix_world @ lod0.data.vertices[vi].co
|
|
|
|
|
for p in lod0.data.polygons if p.material_index == 0
|
|
|
|
|
for vi in p.vertices]
|
|
|
|
|
fbx_h = (max(c.z for c in rinde) - min(c.z for c in rinde)) if rinde else 1.0
|
|
|
|
|
skala = fbx_h / blender_h if blender_h else 1.0
|
|
|
|
|
|
|
|
|
|
print("%-12s %-5s %6d %8.3f %8.3f %8.3f %8.3f"
|
|
|
|
|
% (preset, mit_lods, n, u_r / skala, stamm_r,
|
|
|
|
|
(u_z1 - u_z0) / skala, ansatz))
|
|
|
|
|
|
|
|
|
|
if not (8 <= n <= max_verts * 2):
|
|
|
|
|
fails.append("%s: Huelle hat %d Verts, erwartet 8-%d"
|
|
|
|
|
% (preset, n, max_verts * 2))
|
|
|
|
|
|
|
|
|
|
# DIE Bedingung: Stamm umschlossen, Krone NICHT. Der Stammradius liegt bei
|
|
|
|
|
# wenigen Zentimetern, die Krone bei ein bis zwei Metern - dazwischen ist
|
|
|
|
|
# so viel Luft, dass die Schranke nicht knapp sein muss.
|
|
|
|
|
if u_r / skala < stamm_r * 0.95:
|
|
|
|
|
fails.append("%s: Huellenradius %.3f m unter dem Stammradius %.3f - "
|
|
|
|
|
"der Spieler wuerde in den Stamm laufen"
|
|
|
|
|
% (preset, u_r / skala, stamm_r))
|
|
|
|
|
if u_r / skala > max(stamm_r * 2.5, stamm_r + 0.15):
|
|
|
|
|
fails.append("%s: Huellenradius %.3f m gegen Stammradius %.3f - das "
|
|
|
|
|
"ist keine Stammhuelle mehr"
|
|
|
|
|
% (preset, u_r / skala, stamm_r))
|
|
|
|
|
|
|
|
|
|
# Fuss auf Z = 0 und nicht in die Krone hinaufreichend.
|
|
|
|
|
if abs(u_z0 / skala) > 0.02:
|
|
|
|
|
fails.append("%s: Huelle beginnt bei Z %.3f statt 0" % (preset, u_z0 / skala))
|
|
|
|
|
if (u_z1 - u_z0) / skala > ansatz * 1.35 + 0.05:
|
|
|
|
|
fails.append("%s: Huelle ist %.2f m hoch, Kronenansatz liegt bei "
|
|
|
|
|
"%.2f m - sie reicht in die Krone"
|
|
|
|
|
% (preset, (u_z1 - u_z0) / skala, ansatz))
|
|
|
|
|
|
|
|
|
|
print("")
|
|
|
|
|
if fails:
|
|
|
|
|
print("ERGEBNIS: %d FEHLER" % len(fails))
|
|
|
|
|
for f in fails:
|
|
|
|
|
print(" - " + f)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
print("ERGEBNIS: ALLE CHECKS OK")
|