Tree Gen 1.28.0: Faell-Teile mit exakter Ast-Zuordnung (Punkt 4)

Knopf "Faell-Teile exportieren": Stamm in 2-3 Segmente, jeder Ast als eigenes
Teil SAMT seinen Sub-Aesten und Blatt-Cards, dazu <Name>_Pieces_All.fbx mit
gemeinsamem Ursprung. Je Teil zwei Material-Slots, Pivot am Massenmittelpunkt,
eigene UCX-Huelle.

Die Ast-Zugehoerigkeit ist ein ATTRIBUT, keine Schaetzung - wie von der
UE-Seite vorgeschlagen. 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; nachgemessen: bei Sub Count 0 hat
jeder Ast 28 Verts, bei 3 dann 64, der Stamm bleibt konstant bei 156. Beim
Blatt-Streuen wird ast_id am Punkt abgegriffen und auf die Card gespeichert.

Damit ist die Zuordnung exakt nachzaehlbar, und genau das prueft der Test:
Summe der Cards ueber alle Teile == Cards des Stand-Baums. 170 = 170.

Die Falle, die diese Bilanz aufgedeckt hat: 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, ihre Schlagflaechen werden
gedeckelt.

Massenmittelpunkt flaechengewichtet statt Vertex-Mittelwert: wo das Mesh dicht
tesselliert ist (Astansatz), wuerde der Mittelwert den Pivot dorthin ziehen und
das Teil torkelte beim Fallen.

Alles am REIMPORTIERTEN FBX gemessen: 12 Teile (3 Stamm, 9 Ast), UCX 18-32
Verts im Budget, Pivot maximal 16 mm vom Schwerpunkt, 0 non-manifold Kanten,
Gesamtdatei 5.41 m hoch - also volle Baumhoehe und deckungsgleich
rekonstruierbar.

Nebenbei: _deselect fiel ueber None-Eintraege in der Objektliste, die beim
Entfernen von Objekten zwischen den Teilen staendig auftreten.

Neu: tests/test_faell_teile.py. 21 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 07:40:57 +02:00
co-authored by Claude Opus 4.8
parent 29899c0aa0
commit 95ddcb40b3
8 changed files with 679 additions and 12 deletions
+442 -4
View File
@@ -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_<Name>` - 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 <Name>_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 <Name>_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.