diff --git a/README.md b/README.md index b541d15..0a65a5b 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,41 @@ Zwei Fallen beim Bauen: - `join` loescht das zweite Objekt; jede gehaltene Referenz darauf ist danach tot. Die Aufraeumliste fuehrt deshalb **Namen** statt Objekte. +### Faell-Teile (Punkt 4) + +Kein Laufzeit-Zerlegen: beim Faellen tauscht das Spiel die ISM-Instanz gegen +einen Actor aus vordefinierten Teilen. Knopf **Faell-Teile exportieren**: + +| Teil | Inhalt | +|---|---| +| `_Piece_Stamm01..03` | Stammsegmente, Schnittflaechen mit Deckel | +| `_Piece_Ast01..N` | je ein Ast **samt seinen Sub-Aesten und Blatt-Cards** | +| `_Pieces_All.fbx` | alle Teile an ihrer Originalposition, gemeinsamer Ursprung | + +Je Teil: zwei Material-Slots, Pivot = **Massenmittelpunkt**, eigene `UCX_`-Huelle. + +**Die Ast-Zugehoerigkeit ist ein Attribut, keine Schaetzung.** Der Generator legt +`ast_id` an den Astpunkten ab (0 = Stamm, 1..N = Aeste); Sub-Aeste erben die ID +ihres Astes automatisch, weil sie aus Punkten auf dessen Kurve entstehen — +gemessen: bei `Sub Count` 0 hat jeder Ast 28 Verts, bei 3 dann 64, der Stamm +bleibt bei 156. Beim Blatt-Streuen wird `ast_id` am Punkt abgegriffen und auf +die Card gespeichert. „Welche Card gehoert zu welchem Ast" ist damit eine +Nachschlage-Frage statt einer Nearest-Neighbor-Heuristik — und **exakt +nachzaehlbar**: + +> `tests/test_faell_teile.py` prueft die **Blatt-Bilanz**: Summe der Cards ueber +> alle Teile == Cards des Stand-Baums. Gemessen **170 = 170**. + +**Falle dabei:** Die Z-Schnitte am Stamm zerschnitten anfangs auch Blatt-Cards — +aus einer wurden zwei, die Bilanz stand auf 178 gegen 170, und die Atlas-UVs der +Haelften waeren kaputt gewesen. Eine Card ist ein **unteilbares** Billboard: +sie wandert jetzt als Ganzes in das Segment, in dem ihre Inselmitte liegt. +Nur die Rinde wird geschnitten. + +Alles am **reimportierten** FBX gemessen: 12 Teile, UCX 18–32 Verts im Budget, +Pivot maximal 16 mm vom Schwerpunkt, 0 non-manifold Kanten, Gesamtdatei 5.41 m +hoch (= volle Baumhoehe, die Teile sind also deckungsgleich rekonstruierbar). + ### Proportionen: gemessen, nicht geschaetzt Drei Presets sahen falsch aus. Die Ursachen wurden gemessen bzw. belegt, nicht @@ -606,6 +641,7 @@ gefiltert `.\run_tests.ps1 -Filter panels`. | Datei | prueft | |---|---| | `tests/test_baum_export.py` | Stand-Baum: ein Mesh, zwei Slots, Ursprung, **Kugel-Normalen im exportierten FBX** | +| `tests/test_faell_teile.py` | Faell-Teile: **Blatt-Bilanz 170=170**, Pivot am Massenmittelpunkt, UCX je Teil, Gesamtdatei mit gemeinsamem Ursprung | | `tests/test_blatt_varianten.py` | Card-Sammlung: alle Varianten werden gezogen, seed-stabil, Einzelobjekt weiter moeglich | | `tests/test_proportionen.py` | Baum-Proportionen: Palmenkrone, Strauch-Breite, Setzling-Schlankheit, Branch Count 0 | | `tests/test_bruchstuecke.py` | Bruchstuecke: Volumen 100 % des Muttersteins, dichte Topologie, genau ein Erz-Stueck, Material-Slots, Ursprung, UCX je Teil | diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml index 1375eff..a214939 100644 --- a/blender_manifest_tree.toml +++ b/blender_manifest_tree.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_tree_generator" -version = "1.27.0" +version = "1.28.0" name = "Stylized Tree Generator" tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen" maintainer = "D4rkst3r" diff --git a/dist/index.html b/dist/index.html index 4e434ed..9fea14a 100644 --- a/dist/index.html +++ b/dist/index.html @@ -50,17 +50,17 @@ 34.1KB - stylized_tree_generator-1.27.0 + stylized_tree_generator-1.28.0 Stylized Tree Generator Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen link 4.2.0 - ~ all all - 28.6KB + 33.0KB -

Built 2026-08-06, 04:48

+

Built 2026-08-06, 05:40

diff --git a/dist/index.json b/dist/index.json index c42e433..b53dc80 100644 --- a/dist/index.json +++ b/dist/index.json @@ -82,7 +82,7 @@ "id": "stylized_tree_generator", "name": "Stylized Tree Generator", "tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen", - "version": "1.27.0", + "version": "1.28.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -98,9 +98,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_tree_generator-1.27.0.zip", - "archive_size": 29267, - "archive_hash": "sha256:daeb815cde034e527cdffb0e76a980e964f228b7a0f39070ba78477be9fc3e57" + "archive_url": "./stylized_tree_generator-1.28.0.zip", + "archive_size": 33772, + "archive_hash": "sha256:84ff026fecccddc944a1f847d832f3d8b45b37f142bb9e1cf342a103e2ca290f" } ] } \ No newline at end of file diff --git a/dist/stylized_tree_generator-1.27.0.zip b/dist/stylized_tree_generator-1.27.0.zip deleted file mode 100644 index 457c728..0000000 Binary files a/dist/stylized_tree_generator-1.27.0.zip and /dev/null differ diff --git a/dist/stylized_tree_generator-1.28.0.zip b/dist/stylized_tree_generator-1.28.0.zip new file mode 100644 index 0000000..cd2af8a Binary files /dev/null and b/dist/stylized_tree_generator-1.28.0.zip differ diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py index a4e33ca..446d7a1 100644 --- a/stylized_tree_generator.py +++ b/stylized_tree_generator.py @@ -1,7 +1,7 @@ bl_info = { "name": "Stylized Tree Generator", "author": "D4rkst3r", - "version": (1, 27, 0), + "version": (1, 28, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Tree Gen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", @@ -19,6 +19,7 @@ bl_info = { import bpy import bmesh +import math import os from mathutils import Matrix, Vector import sys @@ -589,8 +590,24 @@ def build_group(): _sock(anch, "Name").default_value = "anchor" L(apos.outputs[0], _sock(anch, "Value")) + # AST-INDEX: jeder Punkt auf dem Stamm ist der Ansatz genau EINES Astes. + # Sein Index wird als Attribut mitgeschleift, damit spaeter feststeht, welche + # Geometrie - und vor allem welche Blatt-Card - zu welchem Ast gehoert. + # Ohne das muesste man die Zuordnung raeumlich raten (Nearest Neighbor); + # so ist sie eine Nachschlage-Frage und exakt nachzaehlbar. + # 0 bleibt fuer den Stamm reserviert, Aeste zaehlen ab 1. + aidx = N("GeometryNodeInputIndex"); aidx.location = (900, 560) + aplus = N("ShaderNodeMath"); aplus.location = (1050, 560) + aplus.operation = 'ADD'; aplus.inputs[1].default_value = 1.0 + L(aidx.outputs[0], aplus.inputs[0]) + astid = N("GeometryNodeStoreNamedAttribute"); astid.location = (1200, 400) + astid.domain = 'POINT'; astid.data_type = 'INT' + L(_out(anch, "Geometry"), _sock(astid, "Geometry")) + _sock(astid, "Name").default_value = "ast_id" + L(aplus.outputs[0], _sock(astid, "Value")) + inst = N("GeometryNodeInstanceOnPoints"); inst.location = (1400, 320) - L(_out(anch, "Geometry"), _sock(inst, "Points")) + L(_out(astid, "Geometry"), _sock(inst, "Points")) L(_out(bsetr, "Curve"), _sock(inst, "Instance")) L(pfin.outputs[0], _sock(inst, "Scale")) if align is not None: @@ -1293,8 +1310,22 @@ def build_leaf_group(rebuild=False): L(slo.outputs[0], rsc.inputs[2]) L(V["Size"], rsc.inputs[3]) + # AST-INDEX auf die Blattpunkte uebernehmen. Der Baum traegt "ast_id" je + # Vertex; beim Streuen wird er am Punkt abgegriffen und mitgespeichert. + # Damit ist "welche Card gehoert zu welchem Ast" spaeter eine + # Nachschlage-Frage und keine Nearest-Neighbor-Schaetzung - und der Test + # kann die Zuordnung exakt nachzaehlen. + lna = N("GeometryNodeInputNamedAttribute"); lna.location = (-320, 60) + lna.data_type = 'INT' + _sock(lna, "Name").default_value = "ast_id" + lstore = N("GeometryNodeStoreNamedAttribute"); lstore.location = (-100, 200) + lstore.domain = 'POINT'; lstore.data_type = 'INT' + L(_out(dist, "Points"), _sock(lstore, "Geometry")) + _sock(lstore, "Name").default_value = "ast_id" + L(_out(lna, "Attribute"), _sock(lstore, "Value")) + inst = N("GeometryNodeInstanceOnPoints"); inst.location = (100, 200) - L(_out(dist, "Points"), _sock(inst, "Points")) + L(_out(lstore, "Geometry"), _sock(inst, "Points")) L(_out(quelle, "Output"), _sock(inst, "Instance")) try: L(V["Use Collection"], _sock(inst, "Pick Instance")) @@ -1493,6 +1524,214 @@ def _merge_for_export(context, tree_ob, leaf_ob, name): return stamm, made + +# --------------------------------------------------------------------------- +# Faell-Teile: Stammsegmente und Aeste als eigene Physik-Assets +# --------------------------------------------------------------------------- + +def _tree_ucx(quell_objs, name, max_verts=32): + """Konvexe Huelle als `UCX_` - gleiches Muster wie bei den Felsen. + + Nur die PUNKTE einlesen: mit `from_mesh` kaemen die Originalflaechen mit und + die Huelle laege obendrauf statt darum herum. + """ + bm = bmesh.new() + for o in quell_objs: + if o.type == 'MESH': + for v in o.data.vertices: + bm.verts.new(o.matrix_world @ v.co) + if len(bm.verts) < 4: + bm.free() + return None + bm.verts.ensure_lookup_table() + res = bmesh.ops.convex_hull(bm, input=bm.verts[:], use_existing_faces=False) + weg = list(set(res.get("geom_interior", []) + res.get("geom_unused", []))) + if weg: + bmesh.ops.delete(bm, geom=weg, context='VERTS') + bm.verts.ensure_lookup_table() + bm.normal_update() + if not bm.faces: + bm.free() + return None + + quelle = [v.co.copy() for v in bm.verts] + winkel = math.radians(1.0) + while len(bm.verts) > max_verts and winkel < math.radians(60.0): + bmesh.ops.dissolve_limit(bm, angle_limit=winkel, + verts=bm.verts[:], edges=bm.edges[:]) + bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.link_faces], + context='VERTS') + bm.verts.ensure_lookup_table() + if not bm.faces: + break + winkel += math.radians(3.0) + bm.normal_update() + + # Das Vereinfachen schneidet Ecken ab - die Huelle waere danach KLEINER als + # das Teil und der Spieler wuerde einsinken. Exakt so weit aufblasen, dass + # wieder alles hineinpasst. + if bm.faces and quelle: + mitte = Vector((0.0, 0.0, 0.0)) + for v in bm.verts: + mitte += v.co + mitte /= max(len(bm.verts), 1) + faktor = 1.0 + for f in bm.faces: + n = f.normal + if n.length_squared < 1e-12: + continue + d = n.dot(f.verts[0].co) + basis = d - n.dot(mitte) + if basis <= 1e-9: + continue + for co in quelle: + noetig = (n.dot(co) - n.dot(mitte)) / basis + if noetig > faktor: + faktor = noetig + if faktor > 1.0: + for v in bm.verts: + v.co = mitte + (v.co - mitte) * faktor + + bmesh.ops.triangulate(bm, faces=bm.faces[:]) + me = bpy.data.meshes.new("UCX_" + name) + bm.to_mesh(me) + bm.free() + ob = bpy.data.objects.new("UCX_" + name, me) + bpy.context.scene.collection.objects.link(ob) + return ob + + +def _mass_center(me): + """Massenmittelpunkt als flaechengewichteter Schwerpunkt der Faces. + + Fuer einen Physik-Actor ist der Pivot der Drehpunkt - liegt er in einer Ecke, + torkelt das Teil beim Fallen. Der reine Vertex-Mittelwert taugt nicht: dort, + wo das Mesh dicht tesselliert ist (Astansatz), zieht er den Punkt hin. + Flaechengewichtet trifft es deutlich besser und funktioniert auch bei + offener Geometrie - die Blatt-Cards sind offen. + """ + gesamt = 0.0 + acc = Vector((0.0, 0.0, 0.0)) + for p in me.polygons: + a = p.area + if a <= 0.0: + continue + acc += p.center * a + gesamt += a + if gesamt <= 0.0: + if not me.vertices: + return Vector((0.0, 0.0, 0.0)) + m = Vector((0.0, 0.0, 0.0)) + for v in me.vertices: + m += v.co + return m / len(me.vertices) + return acc / gesamt + + +def _teil_aus_ids(context, quellen, ids, name, z_von=None, z_bis=None): + """Baut ein Teil-Objekt aus allen Faces, deren ast_id in `ids` liegt. + + `quellen` ist eine Liste (Objekt, Material-Slot). Optional wird zusaetzlich + nach Hoehe geschnitten - so entstehen die Stammsegmente. + """ + rinde = _get_or_create_mat(TREE_MATERIALS[0], (0.29, 0.21, 0.14, 1.0)) + laub = _get_or_create_mat(TREE_MATERIALS[1], (0.18, 0.34, 0.14, 1.0)) + + bm = bmesh.new() + lay = None + for quelle, slot in quellen: + me = quelle.data + attr = me.attributes.get("ast_id") + if attr is None: + continue + werte = [d.value for d in attr.data] + tmp = bmesh.new() + tmp.from_mesh(me) + tmp.transform(quelle.matrix_world) + tmp.verts.ensure_lookup_table() + tmp.faces.ensure_lookup_table() + # Faces behalten, deren Vertices die passende ID tragen. + weg = [] + for f in tmp.faces: + fid = werte[f.verts[0].index] if f.verts[0].index < len(werte) else 0 + if fid not in ids: + weg.append(f) + if weg: + bmesh.ops.delete(tmp, geom=weg, context='FACES') + if z_von is not None or z_bis is not None: + if slot == 1: + # Blatt-Cards NICHT schneiden. Eine Card ist ein unteilbares + # Billboard - halbiert man sie, entstehen zwei Faces mit + # kaputten Atlas-UVs, und die Bilanz stimmt nicht mehr + # (gemessen 178 Cards in den Teilen gegen 170 im Stand-Baum). + # Stattdessen wandert jede Card als GANZES in das Segment, in + # dem ihre Inselmitte liegt. + inseln = [] + offen = set(tmp.faces) + while offen: + start = offen.pop() + insel = [start] + stapel = [start] + while stapel: + f = stapel.pop() + for e in f.edges: + for nf in e.link_faces: + if nf in offen: + offen.discard(nf) + insel.append(nf) + stapel.append(nf) + inseln.append(insel) + weg2 = [] + for insel in inseln: + mz = sum(f.calc_center_median().z for f in insel) / len(insel) + drin = ((z_von is None or mz >= z_von) + and (z_bis is None or mz < z_bis)) + if not drin: + weg2.extend(insel) + if weg2: + bmesh.ops.delete(tmp, geom=weg2, context='FACES') + else: + if z_von is not None: + bmesh.ops.bisect_plane( + tmp, geom=tmp.verts[:] + tmp.edges[:] + tmp.faces[:], + plane_co=Vector((0, 0, z_von)), plane_no=Vector((0, 0, -1)), + clear_outer=True) + if z_bis is not None and tmp.faces: + bmesh.ops.bisect_plane( + tmp, geom=tmp.verts[:] + tmp.edges[:] + tmp.faces[:], + plane_co=Vector((0, 0, z_bis)), plane_no=Vector((0, 0, 1)), + clear_outer=True) + # Schlagflaechen schliessen - offene Segmente taugen nicht als + # Physik-Actor und die UCX-Huelle waere sinnlos. + rand = [e for e in tmp.edges if len(e.link_faces) == 1] + if rand: + bmesh.ops.holes_fill(tmp, edges=rand, sides=0) + if not tmp.faces: + tmp.free() + continue + # Material-Index setzen, bevor es in den Sammel-bmesh geht. + for f in tmp.faces: + f.material_index = slot + hilf = bpy.data.meshes.new("__t") + tmp.to_mesh(hilf) + tmp.free() + bm.from_mesh(hilf) + bpy.data.meshes.remove(hilf) + + if not bm.faces: + bm.free() + return None + + me_neu = bpy.data.meshes.new(name) + bm.to_mesh(me_neu) + bm.free() + me_neu.materials.append(rinde) + me_neu.materials.append(laub) + ob = bpy.data.objects.new(name, me_neu) + context.scene.collection.objects.link(ob) + return ob + + # --------------------------------------------------------------------------- # Addon-UI # --------------------------------------------------------------------------- @@ -1571,6 +1810,29 @@ class TreeGenSettings(PropertyGroup): name="_Frucht-Ebene anlegen", default=False, description="Zusaetzlich _Frucht (gleicher Ursprung) fuer die Frucht-Ebene", ) + piece_trunk_segments: IntProperty( + name="Stamm-Segmente", default=3, min=2, max=3, + description="In wie viele Stuecke der Stamm beim Faellen zerfaellt", + ) + piece_ucx: BoolProperty( + name="UCX je Teil", default=True, + description="Legt je Teil eine konvexe Kollisionshuelle ins FBX", + ) + piece_ucx_verts: IntProperty( + name="UCX max. Verts", default=32, min=8, max=128, + description="Obergrenze fuer die Kollisionshuelle je Teil", + ) + piece_export_combined: BoolProperty( + name="Zusatzdatei mit gemeinsamem Ursprung", default=True, + description=("Schreibt zusaetzlich _Pieces_All.fbx, in der alle " + "Teile an ihrer Originalposition stehen - damit Unreal den " + "zusammengesetzten Baum deckungsgleich aufbauen kann"), + ) + piece_keep: BoolProperty( + name="Teile in der Szene behalten", default=False, + description="Laesst die erzeugten Teile zum Nachschauen stehen", + ) + export_dir: StringProperty( name="Export-Ordner", default="", subtype='DIR_PATH', description="Zielordner fuer die spielfertigen FBX", @@ -1654,10 +1916,15 @@ def _safe_name(name): def _deselect(context): + # Die Objektliste kann nach dem Entfernen von Objekten kurzzeitig None- + # Eintraege und tote Referenzen enthalten - beim Piece-Export passiert das + # zwischen den Teilen staendig. for o in list(context.view_layer.objects): + if o is None: + continue try: o.select_set(False) - except (ReferenceError, RuntimeError): + except (ReferenceError, RuntimeError, AttributeError): pass @@ -1788,6 +2055,165 @@ class TREEGEN_OT_export_game(Operator): return {'FINISHED'} + +class TREEGEN_OT_export_pieces(Operator): + """Exportiert den Baum als Faell-Teile fuer das Zerlegen im Spiel. + + Kein Laufzeit-Zerlegen: beim Faellen tauscht das Spiel die ISM-Instanz gegen + einen Actor aus diesen vordefinierten Teilen. Sie muessen deshalb + vollwertige Assets sein - eigener Massenmittelpunkt als Pivot, zwei + Material-Slots, konvexe UCX-Huelle. + """ + bl_idname = "treegen.export_pieces" + bl_label = "Faell-Teile exportieren" + bl_description = ("Schneidet den Stamm in Segmente, loest die Aeste samt " + "ihren Blatt-Cards heraus und schreibt je Teil eine FBX " + "mit UCX-Huelle") + bl_options = {'REGISTER'} + + def execute(self, context): + s = context.scene.tree_gen_settings + verzeichnis = bpy.path.abspath(s.export_dir) if s.export_dir else "" + if not verzeichnis or not os.path.isdir(verzeichnis): + self.report({'ERROR'}, "Bitte einen gueltigen Export-Ordner waehlen.") + return {'CANCELLED'} + + baeume = [o for o in context.selected_objects + if o.type == 'MESH' + and not o.name.endswith(("_Leaf", "_Frucht"))] + if not baeume: + self.report({'ERROR'}, "Keinen Baum ausgewaehlt.") + return {'CANCELLED'} + + gesamt = 0 + for baum in baeume: + gesamt += self._ein_baum(context, s, baum, verzeichnis) + + self.report({'INFO'}, "%d Faell-Teil(e) nach %s." % (gesamt, verzeichnis)) + return {'FINISHED'} + + def _ein_baum(self, context, s, baum, verzeichnis): + blatt = bpy.data.objects.get(baum.name + "_Leaf") + basis = _safe_name(baum.name) + + # Ausgewertete Kopien: die GN-Modifier muessen aufgeloest sein, sonst + # gibt es keine Faces zum Schneiden. + dg = context.evaluated_depsgraph_get() + temp = [] + + def _ausgewertet(src): + me = bpy.data.meshes.new_from_object( + src.evaluated_get(dg), preserve_all_data_layers=True, depsgraph=dg) + ob = bpy.data.objects.new(src.name + "__src", me) + ob.matrix_world = src.matrix_world.copy() + context.scene.collection.objects.link(ob) + temp.append(ob.name) + return ob + + stamm_src = _ausgewertet(baum) + blatt_src = _ausgewertet(blatt) if blatt is not None else None + + quellen = [(stamm_src, 0)] + if blatt_src is not None: + quellen.append((blatt_src, 1)) + + # Welche Ast-IDs gibt es? + attr = stamm_src.data.attributes.get("ast_id") + if attr is None: + self.report({'WARNING'}, "%s traegt kein ast_id - bitte den Baum neu " + "erzeugen." % baum.name) + for n in temp: + o = bpy.data.objects.get(n) + if o: + bpy.data.objects.remove(o, do_unlink=True) + return 0 + ast_ids = sorted({d.value for d in attr.data if d.value > 0}) + + zs = [(stamm_src.matrix_world @ v.co).z for v in stamm_src.data.vertices] + z_unten, z_oben = min(zs), max(zs) + + teile = [] + # --- Stamm in Segmente ------------------------------------------ + n_seg = s.piece_trunk_segments + # Nur bis zum untersten Astansatz schneiden waere schoener, ist aber + # unnoetig: die Aeste sind eigene Teile, der Stamm-Anteil bleibt uebrig. + grenzen = [z_unten + (z_oben - z_unten) * i / n_seg + for i in range(n_seg + 1)] + for i in range(n_seg): + name = "%s_Piece_Stamm%02d" % (basis, i + 1) + ob = _teil_aus_ids(context, quellen, {0}, name, + z_von=grenzen[i], z_bis=grenzen[i + 1]) + if ob is not None: + teile.append(ob) + + # --- Aeste, jeder mit SEINEN Blatt-Cards ------------------------ + for k, aid in enumerate(ast_ids): + name = "%s_Piece_Ast%02d" % (basis, k + 1) + ob = _teil_aus_ids(context, quellen, {aid}, name) + if ob is not None: + teile.append(ob) + + # --- Gesamtdatei mit GEMEINSAMEM Ursprung ----------------------- + # Damit Unreal den zusammengesetzten Baum deckungsgleich rekonstruieren + # kann: hier stehen alle Teile noch an ihrer Originalposition. + if s.piece_export_combined and teile: + _deselect(context) + for t in teile: + t.select_set(True) + context.view_layer.objects.active = teile[0] + bpy.ops.export_scene.fbx( + filepath=os.path.join(verzeichnis, basis + "_Pieces_All.fbx"), + use_selection=True, object_types={'MESH'}, + use_mesh_modifiers=False, mesh_smooth_type='OFF', + use_triangles=True, use_custom_props=True, colors_type='SRGB', + add_leaf_bones=False, bake_anim=False, path_mode='AUTO', + apply_scale_options='FBX_SCALE_ALL') + + # --- Je Teil eine Datei, Pivot = Massenmittelpunkt -------------- + geschrieben = 0 + for t in teile: + schwer = _mass_center(t.data) + t.data.transform(Matrix.Translation(-schwer)) + t.matrix_basis = Matrix.Identity(4) + + hull = _tree_ucx([t], t.name, max_verts=s.piece_ucx_verts) \ + if s.piece_ucx else None + + _deselect(context) + t.select_set(True) + if hull is not None: + hull.select_set(True) + context.view_layer.objects.active = t + bpy.ops.export_scene.fbx( + filepath=os.path.join(verzeichnis, t.name + ".fbx"), + use_selection=True, object_types={'MESH'}, + use_mesh_modifiers=False, mesh_smooth_type='OFF', + use_triangles=True, use_custom_props=True, colors_type='SRGB', + add_leaf_bones=False, bake_anim=False, path_mode='AUTO', + apply_scale_options='FBX_SCALE_ALL') + geschrieben += 1 + if hull is not None: + hm = hull.data + bpy.data.objects.remove(hull, do_unlink=True) + if hm.users == 0: + bpy.data.meshes.remove(hm) + + if not s.piece_keep: + for t in teile: + me = t.data + bpy.data.objects.remove(t, do_unlink=True) + if me.users == 0: + bpy.data.meshes.remove(me) + for n in temp: + o = bpy.data.objects.get(n) + if o is not None: + me = o.data + bpy.data.objects.remove(o, do_unlink=True) + if me is not None and me.users == 0: + bpy.data.meshes.remove(me) + return geschrieben + + class TREEGEN_OT_randomize(Operator): bl_idname = "object.treegen_randomize" bl_label = "Zufaellig" @@ -2083,6 +2509,17 @@ class VIEW3D_PT_tree_export(_TreeSub): layout.prop(s, "sphere_normals") layout.prop(s, "export_dir") layout.operator("treegen.export_game", icon='EXPORT') + layout.separator() + box = layout.box() + box.label(text="Faell-Teile", icon='MOD_BUILD') + box.prop(s, "piece_trunk_segments") + box.prop(s, "piece_ucx") + row = box.row() + row.active = s.piece_ucx + row.prop(s, "piece_ucx_verts") + box.prop(s, "piece_export_combined") + box.prop(s, "piece_keep") + box.operator("treegen.export_pieces", icon='MOD_BUILD') layout.label(text="Slot 0 = %s, Slot 1 = %s" % (TREE_MATERIALS[0], TREE_MATERIALS[1]), icon='MATERIAL') @@ -2091,6 +2528,7 @@ classes = ( TreeGenSettings, TREEGEN_OT_randomize, TREEGEN_OT_export_game, + TREEGEN_OT_export_pieces, TREEGEN_OT_leaves, TREEGEN_OT_create, # Eltern-Panel zuerst, sonst findet Blender die bl_parent_id nicht. diff --git a/tests/test_faell_teile.py b/tests/test_faell_teile.py new file mode 100644 index 0000000..ca688d9 --- /dev/null +++ b/tests/test_faell_teile.py @@ -0,0 +1,193 @@ +"""Punkt 4: Faell-Teile (Stammsegmente + Aeste mit ihren Blatt-Cards). + +Gemessen wird am REIMPORTIERTEN FBX, nicht in der Szene - gleiche Messlatte wie +bei den Punkten 1-3. + +Die Kernaussage ist die Card-Bilanz: die Summe der Blatt-Faces ueber alle Teile +muss der Blatt-Zahl des Stand-Baums entsprechen. Das ist nur pruefbar, weil die +Ast-Zugehoerigkeit als Attribut `ast_id` durch die Instanzierung geschleift wird +- mit einer raeumlichen Nearest-Neighbor-Schaetzung waere "stimmt die Zuordnung" +keine beantwortbare Frage, sondern Ansichtssache. + +Aufruf: blender --background --factory-startup --python tests/test_faell_teile.py +""" +import os +import sys +import tempfile +import importlib.util +from collections import Counter + +import bpy +import bmesh +from mathutils import Vector + +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.preset = 'baum' +s.use_growth = False +s.apply_modifier = True +s.art_name = "Eiche" +s.variant = 1 +s.export_dir = OUT +s.piece_trunk_segments = 3 +s.piece_ucx = True +s.piece_export_combined = True + +bpy.ops.object.treegen_create() +baum = [o for o in bpy.context.scene.objects + if o.type == 'MESH' and o.name.startswith("Eiche_01") + and not o.name.endswith(("_Leaf", "_Frucht"))][0] +for o in bpy.context.scene.objects: + o.select_set(o is baum) +bpy.context.view_layer.objects.active = baum +s.leaf_card = card +bpy.ops.object.treegen_leaves() +blatt = bpy.data.objects.get(baum.name + "_Leaf") + +# Referenz: wie viele Blatt-Faces hat der Stand-Baum? +dg = bpy.context.evaluated_depsgraph_get() +ev = blatt.evaluated_get(dg) +mb = ev.to_mesh() +cards_gesamt = len(mb.polygons) +attr = mb.attributes.get("ast_id") +ids_stand = Counter(d.value for d in attr.data) if attr else Counter() +ev.to_mesh_clear() +print("Stand-Baum: %d Blatt-Faces, ast_id auf %d Gruppen" + % (cards_gesamt, len(ids_stand))) +if not ids_stand: + fails.append("Die Blatt-Cards tragen kein ast_id - die Zuordnung waere " + "nur zu raten") + +for o in bpy.context.scene.objects: + o.select_set(o is baum) +bpy.context.view_layer.objects.active = baum +namen_vorher = sorted(o.name for o in bpy.context.scene.objects) + +bpy.ops.treegen.export_pieces() + +if sorted(o.name for o in bpy.context.scene.objects) != namen_vorher: + fails.append("Szene veraendert - die Arbeitsebene soll bleiben") + +dateien = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx")) +einzeln = [f for f in dateien if "_Piece_" in f and not f.endswith("_All.fbx")] +gesamt = [f for f in dateien if f.endswith("_Pieces_All.fbx")] +stamm = [f for f in einzeln if "_Stamm" in f] +aeste = [f for f in einzeln if "_Ast" in f] +print("Export: %d Teile (%d Stamm, %d Ast), Gesamtdatei: %s" + % (len(einzeln), len(stamm), len(aeste), "ja" if gesamt else "NEIN")) + +if len(stamm) != s.piece_trunk_segments: + fails.append("%d Stammsegmente, erwartet %d" + % (len(stamm), s.piece_trunk_segments)) +if not aeste: + fails.append("Keine Ast-Teile exportiert") +if not gesamt: + fails.append("Keine Gesamtdatei mit gemeinsamem Ursprung") + +print("") +print("%-30s %6s %6s %6s %7s %s" % + ("Teil", "Rinde", "Blatt", "UCX", "|Pivot|", "Topologie")) + +blatt_summe = 0 +for f in sorted(einzeln): + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, f)) + meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] + hull = [o for o in meshes if o.name.startswith("UCX_")] + koerper = [o for o in meshes if not o.name.startswith("UCX_")] + if not koerper: + fails.append("%s: kein Mesh" % f) + continue + ob = koerper[0] + m = ob.data + rinde = sum(1 for p in m.polygons if p.material_index == 0) + laub = sum(1 for p in m.polygons if p.material_index == 1) + blatt_summe += laub + + # Pivot = Massenmittelpunkt: der Schwerpunkt muss NAHE am Ursprung liegen, + # sonst torkelt das Teil als Physik-Actor um eine Ecke. + schwer = tg._mass_center(m) + abstand = (ob.matrix_world @ schwer).length + diag = max((max(v.co[i] for v in m.vertices) - min(v.co[i] for v in m.vertices)) + for i in range(3)) if m.vertices else 1.0 + + bm = bmesh.new(); bm.from_mesh(m) + nm = sum(1 for e in bm.edges if len(e.link_faces) > 2) + bm.free() + + nv = len(hull[0].data.vertices) if hull else 0 + print("%-30s %6d %6d %6s %7.3f %s" + % (f[:-4], rinde, laub, nv or "-", abstand, + "ok" if nm == 0 else "%d non-manifold" % nm)) + + if len(m.materials) != 2: + fails.append("%s: %d Slots, erwartet 2" % (f, len(m.materials))) + if not hull: + fails.append("%s: keine UCX-Huelle" % f) + else: + if hull[0].name != "UCX_" + f[:-4]: + fails.append("%s: Huelle heisst '%s', Unreal erwartet 'UCX_%s'" + % (f, hull[0].name, f[:-4])) + if nv > s.piece_ucx_verts: + fails.append("%s: UCX hat %d Verts, Budget %d" + % (f, nv, s.piece_ucx_verts)) + # Der Schwerpunkt darf nur einen Bruchteil der groessten Ausdehnung + # vom Ursprung entfernt liegen. + if abstand > diag * 0.15: + fails.append("%s: Schwerpunkt %.3f m vom Pivot entfernt (Ausdehnung " + "%.2f m) - als Physik-Actor wuerde das Teil torkeln" + % (f, abstand, diag)) + +# --- Die Bilanz: Summe der Cards ueber alle Teile == Stand-Baum ---------- +print("") +print("Blatt-Bilanz: %d Cards in den Teilen, %d im Stand-Baum" + % (blatt_summe, cards_gesamt)) +if blatt_summe != cards_gesamt: + fails.append("Blatt-Bilanz stimmt nicht: %d in den Teilen, %d im Stand-Baum " + "- es gehen Cards verloren oder werden doppelt zugeordnet" + % (blatt_summe, cards_gesamt)) + +# --- Gesamtdatei: Teile stehen an ihrer Originalposition ---------------- +if gesamt: + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, gesamt[0])) + teile = [o for o in bpy.context.scene.objects if o.type == 'MESH'] + alle = [o.matrix_world @ v.co for o in teile for v in o.data.vertices] + hoehe = max(c.z for c in alle) - min(c.z for c in alle) + print("Gesamtdatei: %d Teile, Hoehe %.2f m" % (len(teile), hoehe)) + if len(teile) != len(einzeln): + fails.append("Gesamtdatei hat %d Teile, einzeln waren es %d" + % (len(teile), len(einzeln))) + # Zusammengesetzt muss der Baum wieder seine volle Hoehe haben - das ist + # der Beleg, dass die Teile deckungsgleich rekonstruierbar sind. + if hoehe < 3.0: + fails.append("Gesamtdatei nur %.2f m hoch - die Teile stehen nicht an " + "ihrer Originalposition" % hoehe) + +print("") +if fails: + print("ERGEBNIS: %d FEHLER" % len(fails)) + for x in fails: + print(" - " + x) + sys.exit(1) +print("ERGEBNIS: ALLE CHECKS OK")