diff --git a/README.md b/README.md index 3e645d8..02f12d8 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,43 @@ Der Kaktus erzeugt regulaer **0 Cards** — kein Ast ist duenner als `Max Radius und das ist botanisch richtig. Vorher lief das still durch und man stand vor einer leeren `_Leaf`-Ebene; jetzt sagt der Operator es und nennt den Regler. +### UCX auch in der Gesamtdatei - und zwei Fehler, die dabei auffielen + +`_Pieces_All.fbx` kam ohne Kollision an, waehrend die Einzeldateien welche +hatten. Jetzt bekommt jedes Teil auch dort seine Huelle, gleiche Konvention wie +beim Stand-Baum: `UCX_` als Geschwister, kein Elternteil. Sie +entstehen VOR dem Umsetzen der Pivots - in der Gesamtdatei stehen alle Teile +noch an ihrer Originalposition. + +**Die Huellen umschlossen das Laub mit.** `_tree_ucx` las alle Vertices eines +Teils, also auch die Blatt-Cards. Gemessen am `baum`-Preset war die Huelle +dadurch **1.4- bis 2.1-mal** so gross wie das Holz - beim obersten Stammsegment, +das die Krone traegt, 1.67x. Jetzt nur noch Material-Slot 0 (Rinde). Die +Spielseite hatte das fuer ihre Interim-Boxen laengst so gemacht. + +**Und das Aufweiten war fuer schlanke Teile kaputt.** Nach dem Vereinfachen +wurde die Huelle wieder aufgeblasen, damit alles hineinpasst - durch Skalieren +um die Huellenmitte. Bei langgestreckten Teilen entgleist das: liegt eine +Flaeche fast durch die Mitte, wird der Hebel winzig und der noetige Faktor +explodiert. Gemessen an einem Stammsegment (Laenge zu Dicke rund 13:1) +**Faktor 4.2**. Bei der dicken Eiche blieb es bei 1.0-1.4 - deshalb ist es nie +aufgefallen. Erst der Wechsel auf Rinde-only machte die Teile schlank genug, +dass es durchschlug. + +Jetzt wird nicht mehr skaliert, sondern je Flaeche der ECHTE Fehlbetrag +gemessen (wie weit liegt der schlimmste Quellpunkt vor ihrer Ebene?) und die +Vertices um genau diesen Betrag nach aussen geschoben - eine Verschiebung in +Metern, kein Verhaeltnis, sie kann nicht explodieren. Das laeuft ITERATIV: ein +Vertex gehoert mehreren Flaechen an und wandert entlang seiner eigenen Normale, +die Ebene verschiebt sich also nur um den projizierten Anteil. Ein Durchgang +liess einen Rest stehen (der Test schlug an), nach wenigen ist er weg. Bleibt +doch etwas uebrig, faellt die Funktion auf die unvereinfachte Huelle zurueck - +ein paar Vertices mehr kosten nichts, eine vierfach zu grosse Kollision schon. + +Ergebnis am 12-teiligen Baum: alle Huellen 14-31 Verts (Budget 32), keine +groesser als das 1.35-fache ihres Holzes. Gegenproben: ohne Rinde-Filter 10 +Fehler (1.41x bis 2.06x), ohne Huellen in der Gesamtdatei 1 Fehler. + ### Stamm-UCX fuer den Stand-Baum Die Stand-Baeume bekommen eine konvexe Kollisionshuelle um den **Stamm**, vom diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml index 9008dab..9fe096c 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.42.0" +version = "1.43.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 dc287cb..d2ba893 100644 --- a/dist/index.html +++ b/dist/index.html @@ -50,17 +50,17 @@ 34.1KB - stylized_tree_generator-1.42.0 + stylized_tree_generator-1.43.0 Stylized Tree Generator Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen link 4.2.0 - ~ all all - 47.6KB + 49.0KB -

Built 2026-08-07, 04:18

+

Built 2026-08-07, 04:53

diff --git a/dist/index.json b/dist/index.json index 1ff78fc..0aca9b6 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.42.0", + "version": "1.43.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -98,9 +98,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_tree_generator-1.42.0.zip", - "archive_size": 48711, - "archive_hash": "sha256:e1b5eb8cb83ecabb60d24ce9c6be75be4c3d168aee86b9a3baa2856bdf440e80" + "archive_url": "./stylized_tree_generator-1.43.0.zip", + "archive_size": 50130, + "archive_hash": "sha256:2d87c780f90befc057e6895762c151bd6f4e9514485f5eb98b64993f6304893f" } ] } \ No newline at end of file diff --git a/dist/stylized_tree_generator-1.42.0.zip b/dist/stylized_tree_generator-1.42.0.zip deleted file mode 100644 index 23215e2..0000000 Binary files a/dist/stylized_tree_generator-1.42.0.zip and /dev/null differ diff --git a/dist/stylized_tree_generator-1.43.0.zip b/dist/stylized_tree_generator-1.43.0.zip new file mode 100644 index 0000000..dd3a23f Binary files /dev/null and b/dist/stylized_tree_generator-1.43.0.zip differ diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py index c1858f5..8e3a169 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, 42, 0), + "version": (1, 43, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Tree Gen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", @@ -2211,6 +2211,13 @@ def _tree_ucx(quell_objs, name, max_verts=32, punkte=None): return None quelle = [v.co.copy() for v in bm.verts] + + # Rohzustand sichern. Das Vereinfachen unten kann bei LANGGESTRECKTEN + # Teilen entgleisen - siehe die Notbremse nach dem Aufblasen. + bm.verts.index_update() + roh_verts = [v.co.copy() for v in bm.verts] + roh_faces = [[v.index for v in f.verts] for f in bm.faces] + 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, @@ -2224,29 +2231,75 @@ def _tree_ucx(quell_objs, name, max_verts=32, punkte=None): 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. + # das Teil und der Spieler wuerde einsinken. Also wieder aufweiten. + # + # NICHT durch Skalieren um die Huellenmitte. Das war der erste Ansatz und + # entgleist bei langgestreckten Teilen: liegt eine Flaeche fast durch die + # Mitte, wird der Hebel winzig und der noetige Faktor explodiert. Gemessen + # an einem Stammsegment des schlanken "baum"-Presets (Laenge zu Dicke rund + # 13:1) kam Faktor 4.2 heraus - die Huelle war viermal so gross wie das Holz. + # Bei der dicken Eiche blieb es bei 1.0-1.4, deshalb ist es lange nicht + # aufgefallen. + # + # Stattdessen je Flaeche den ECHTEN Fehlbetrag messen (wie weit liegt der + # schlimmste Quellpunkt vor ihrer Ebene?) und die Vertices um genau diesen + # Betrag nach aussen schieben. Das ist eine Verschiebung in Metern, kein + # Verhaeltnis - sie kann nicht explodieren. 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 + def _rest(): + """Groesster Abstand, mit dem ein Quellpunkt noch draussen liegt.""" + r = 0.0 + for f in bm.faces: + n = f.normal + if n.length_squared < 1e-12: + continue + d0 = n.dot(f.verts[0].co) + r = max(r, max((n.dot(co) - d0) for co in quelle)) + return r + + # ITERATIV. Ein Vertex gehoert mehreren Flaechen an und wandert entlang + # SEINER Normale - die Ebene verschiebt sich dadurch nur um den auf sie + # projizierten Anteil, also weniger als noetig. Ein einzelner Durchgang + # laesst deshalb einen Rest stehen (gemessen, der Test schlug an). Jeder + # weitere Durchgang verkleinert ihn; nach wenigen ist er weg. + for _ in range(8): + rest = _rest() + if rest <= 1e-4: + break + schuld = {} + for f in bm.faces: + n = f.normal + if n.length_squared < 1e-12: + continue + d0 = n.dot(f.verts[0].co) + ueber = max((n.dot(co) - d0) for co in quelle) + if ueber > 1e-9: + for v in f.verts: + if ueber > schuld.get(v, 0.0): + schuld[v] = ueber + if not schuld: + break + for v, d in schuld.items(): + n = v.normal + if n.length_squared < 1e-12: + continue + v.co = v.co + n.normalized() * d + bm.normal_update() + + # Notbremse: geht es nicht auf, ist die vereinfachte Huelle als + # Kollision unbrauchbar - dann lieber die unvereinfachte mit ein paar + # Vertices mehr. + if _rest() > 1e-4 and roh_faces: + bm.free() + bm = bmesh.new() + neu_v = [bm.verts.new(c) for c in roh_verts] + bm.verts.ensure_lookup_table() + for f in roh_faces: + try: + bm.faces.new([neu_v[i] for i in f]) + except ValueError: + pass # Doppelflaeche - die gibt es schon + bm.normal_update() bmesh.ops.triangulate(bm, faces=bm.faces[:]) me = bpy.data.meshes.new("UCX_" + name) @@ -2257,6 +2310,27 @@ def _tree_ucx(quell_objs, name, max_verts=32, punkte=None): return ob +def _rinden_punkte(ob): + """Weltkoordinaten der RINDEN-Vertices (Material-Slot 0) eines Faell-Teils. + + Blatt-Cards gehoeren nicht in eine Kollisionshuelle: sie stehen weit vom + Holz ab, und der Spieler soll gegen den Ast stossen, nicht gegen das Laub. + Gemessen an der Eiche blaeht das Laub die Huellen-Diagonale beim Ast um + 8-19 % auf - und beim OBERSTEN Stammsegment, das die Krone traegt, um 67 % + (1.75 m statt 1.05 m). + + Fallback auf alle Vertices, falls ein Teil keinen zweiten Slot hat: ein + reines Stammsegment ist ohnehin komplett Rinde. + """ + me = ob.data + if len(me.materials) > 1: + idx = {vi for p in me.polygons if p.material_index == 0 + for vi in p.vertices} + if idx: + return [ob.matrix_world @ me.vertices[i].co for i in idx] + return [ob.matrix_world @ v.co for v in me.vertices] + + def _stamm_punkte(tree_obj, matrix): """Stamm-Koordinaten vom Fuss bis zum KRONENANSATZ, in Weltkoordinaten. @@ -3044,9 +3118,26 @@ class TREEGEN_OT_export_pieces(Operator): # Damit Unreal den zusammengesetzten Baum deckungsgleich rekonstruieren # kann: hier stehen alle Teile noch an ihrer Originalposition. if s.piece_export_combined and teile: + # Auch die Gesamtdatei bekommt je Teil seine Huelle - dieselbe + # Geschwister-Konvention wie beim Stand-Baum: `UCX_` + # neben dem Teil, kein Elternteil. Vorher kam die Datei ohne + # Kollision an, waehrend die Einzeldateien welche hatten. + # + # Sie entstehen HIER und nicht in der Schleife weiter unten: dort + # sitzt jedes Teil schon auf seinem eigenen Massenmittelpunkt, in + # der Gesamtdatei stehen alle noch an ihrer Originalposition. + hullen = [] + if s.piece_ucx: + for t in teile: + h = _tree_ucx(None, t.name, max_verts=s.piece_ucx_verts, + punkte=_rinden_punkte(t)) + if h is not None: + hullen.append(h) _deselect(context) for t in teile: t.select_set(True) + for h in hullen: + h.select_set(True) context.view_layer.objects.active = teile[0] bpy.ops.export_scene.fbx( filepath=os.path.join(verzeichnis, basis + "_Pieces_All.fbx"), @@ -3055,6 +3146,11 @@ class TREEGEN_OT_export_pieces(Operator): 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') + for h in hullen: + hm = h.data + bpy.data.objects.remove(h, do_unlink=True) + if hm.users == 0: + bpy.data.meshes.remove(hm) # --- Je Teil eine Datei, Pivot = Massenmittelpunkt -------------- geschrieben = 0 @@ -3063,7 +3159,10 @@ class TREEGEN_OT_export_pieces(Operator): t.data.transform(Matrix.Translation(-schwer)) t.matrix_basis = Matrix.Identity(4) - hull = _tree_ucx([t], t.name, max_verts=s.piece_ucx_verts) \ + # Huelle aus der RINDE, nicht aus allen Vertices: das Laub blaehte + # sie beim obersten Stammsegment gemessen um 67 % auf. + hull = _tree_ucx(None, t.name, max_verts=s.piece_ucx_verts, + punkte=_rinden_punkte(t)) \ if s.piece_ucx else None _deselect(context) diff --git a/tests/test_faell_teile.py b/tests/test_faell_teile.py index ca688d9..9e85679 100644 --- a/tests/test_faell_teile.py +++ b/tests/test_faell_teile.py @@ -13,6 +13,7 @@ Aufruf: blender --background --factory-startup --python tests/test_faell_teile.p """ import os import sys +import math import tempfile import importlib.util from collections import Counter @@ -171,13 +172,64 @@ if blatt_summe != cards_gesamt: 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'] + meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] + teile = [o for o in meshes if not o.name.startswith("UCX_")] + hullen = [o for o in meshes if o.name.startswith("UCX_")] 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)) + print("Gesamtdatei: %d Teile, %d Huellen, Hoehe %.2f m" + % (len(teile), len(hullen), hoehe)) if len(teile) != len(einzeln): fails.append("Gesamtdatei hat %d Teile, einzeln waren es %d" % (len(teile), len(einzeln))) + + # Die Gesamtdatei kam frueher OHNE Huellen an, waehrend die Einzeldateien + # welche hatten - gemeldet von der Spielseite, die interimsweise eigene + # Boxen setzen musste. Je Teil GENAU eine Huelle, gleiche Konvention wie + # beim Stand-Baum: `UCX_` als Geschwister, kein Elternteil. + namen = {o.name for o in teile} + for h in hullen: + if h.parent is not None: + fails.append("Gesamtdatei: Huelle '%s' haengt unter '%s'" + % (h.name, h.parent.name)) + if h.name[4:] not in namen: + fails.append("Gesamtdatei: Huelle '%s' gehoert zu keinem Teil" + % h.name) + fehlend = sorted(n for n in namen if ("UCX_" + n) not in + {h.name for h in hullen}) + if fehlend: + fails.append("Gesamtdatei: %d Teile ohne Huelle (%s)" + % (len(fehlend), ", ".join(fehlend[:3]))) + + # Und die Huelle umschliesst das HOLZ, nicht das Laub. Beim obersten + # Stammsegment, das die Krone traegt, war der Unterschied gemessen 67 % + # (Diagonale 1.75 m statt 1.05 m) - eine Kollision, die weit ins Laub ragt. + nach_name = {o.name: o for o in teile} + for h in hullen: + koerper = nach_name.get(h.name[4:]) + if koerper is None: + continue + hc = [h.matrix_world @ v.co for v in h.data.vertices] + rinde_idx = {vi for p in koerper.data.polygons + if p.material_index == 0 for vi in p.vertices} + if not rinde_idx: + continue + rc = [koerper.matrix_world @ koerper.data.vertices[i].co + for i in rinde_idx] + + def spanne(pts): + lo = [min(c[i] for c in pts) for i in range(3)] + hi = [max(c[i] for c in pts) for i in range(3)] + return math.dist(lo, hi) + + h_d, r_d = spanne(hc), spanne(rc) + if h_d < r_d * 0.95: + fails.append("%s: Huelle %.2f kleiner als die Rinde %.2f" + % (h.name, h_d, r_d)) + if h_d > r_d * 1.35: + fails.append("%s: Huelle %.2f gegen Rinde %.2f (%.2fx) - sie " + "umschliesst das Laub mit" + % (h.name, h_d, r_d, h_d / r_d)) # Zusammengesetzt muss der Baum wieder seine volle Hoehe haben - das ist # der Beleg, dass die Teile deckungsgleich rekonstruierbar sind. if hoehe < 3.0: