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:
@@ -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 |
|
||||
|---|---|
|
||||
| `<Name>_Piece_Stamm01..03` | Stammsegmente, Schnittflaechen mit Deckel |
|
||||
| `<Name>_Piece_Ast01..N` | je ein Ast **samt seinen Sub-Aesten und Blatt-Cards** |
|
||||
| `<Name>_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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+3
-3
@@ -50,17 +50,17 @@
|
||||
<td>34.1KB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><tt><a href="./stylized_tree_generator-1.27.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_tree_generator-1.27.0</a></tt></td>
|
||||
<td><tt><a href="./stylized_tree_generator-1.28.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_tree_generator-1.28.0</a></tt></td>
|
||||
<td>Stylized Tree Generator</td>
|
||||
<td>Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen</td>
|
||||
<td><a href="https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator">link</a></td>
|
||||
<td>4.2.0 - ~</td>
|
||||
<td>all</td>
|
||||
<td>all</td>
|
||||
<td>28.6KB</td>
|
||||
<td>33.0KB</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<center><p>Built 2026-08-06, 04:48</p></center>
|
||||
<center><p>Built 2026-08-06, 05:40</p></center>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+4
-4
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+442
-4
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user