"""Instanced tree assets — the model side of `--tree-style`. The Shapespark low-poly plant kit is reduced to runtime variants by importing each split glTF once, baking the source object's orientation into a mesh copy, measuring it, then linking one lightweight object per tree that reuses that datablock. Nothing is duplicated per tree, so the .blend and the exported GLB carry each mesh and each texture exactly once no matter how many trees are planted. Materials from split glTF files are reused by base name because Blender creates `branch-01.001`, `branch-01.002`, ... duplicates across imports even when the images are shared by filepath. Deduping material slots keeps the Cesium exporter from baking the same foliage texture many times. A third style, `polyhaven`, used to live here. It appended what the Poly Haven island_tree_01 file calls its LOD1 objects, but those are not whole trees: the branch parts are 0.4-unit twigs and the leaf parts are flat clusters hanging below their own origin, both meant to be scattered by the geometry-nodes setup in that file. Planting them directly gave twigs, which is what sent us looking for other tree assets. Removed along with the 78MB asset. Older model tree styles were also retired after Shapespark trees proved visually better and lighter for the Cesium preview. Their detailed experiment notes live in `docs/changelog.md` and the Trellis asset-generation spec. """ import math import os from collections import namedtuple import bpy MODEL_ROOT = os.path.abspath(os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets", "models", )) SHAPESPARK_DIR = os.path.join(MODEL_ROOT, "custom", "shapespark_plants") SHAPESPARK_TREE_IDS = ( "tree-01-1", "tree-01-2", "tree-01-3", "tree-01-4", "tree-02-1", "tree-02-2", "tree-02-3", "tree-02-4", "tree-03-1", "tree-03-2", "tree-03-3", "tree-03-4", ) MIN_TREE_HEIGHT = 4.0 # Shapespark leaf cards use alpha-cut foliage textures. ALPHA_CUTOFF = 0.5 # Golden angle. Successive trees face directions that never repeat and never # settle into a pattern, so a tree_row reads as planted rather than stamped. GOLDEN_TURN = 0.61803398875 SCALE_JITTER = 0.14 TILT_JITTER = math.radians(3.0) # meshes — mesh datablocks instanced together at one transform # height — the variant's own height, what a target height is divided by # base_z — the variant's own ground line, what drops the trunk onto z=0 TreeVariant = namedtuple("TreeVariant", "meshes height base_z") # -------------------------------------------------------------------------- # import plumbing def _bake(obj, name): """Copy obj's mesh with its rotation and scale applied, but not its position. Orientation has to be baked because imported glTF objects may carry source rotations or scale on the object. Position must not be baked: a source file's translation is where the artist parked the model in its own scene, and `base_z` measures whatever ground line the mesh ends up with so assemble() can correct for it per instance. """ mesh = obj.data.copy() mesh.transform(obj.matrix_world.to_3x3().to_4x4()) mesh.name = name mesh.use_fake_user = True return mesh def _measure(meshes): """Return (height, base_z) for a variant's meshes in their shared space.""" zs = [vertex.co.z for mesh in meshes for vertex in mesh.vertices] if not zs: return 1.0, 0.0 low, high = min(zs), max(zs) return max(high - low, 1e-6), low def _discard(objects): """Remove imported objects along with the meshes they brought in. The _bake copies carry a fake user and survive. Dropping only the objects would strand their original meshes at zero users, which in turn keeps the source materials and their megabytes of texture alive in the file. """ for obj in objects: mesh = obj.data if obj.type == "MESH" else None bpy.data.objects.remove(obj, do_unlink=True) if mesh is not None and mesh.users == 0: bpy.data.meshes.remove(mesh) def _purge_orphans(before_materials, before_images): """Drop the materials and images an import created that nothing now uses. glTF import creates temporary datablocks for source material descriptions. Once split-asset duplicates are collapsed, unused leftovers should not stay packed into the .blend. """ for material in set(bpy.data.materials) - before_materials: if material.users == 0: bpy.data.materials.remove(material) for image in set(bpy.data.images) - before_images: if image.users == 0: bpy.data.images.remove(image) def _base_name(name): if len(name) > 4 and name[-4] == "." and name[-3:].isdigit(): return name[:-4] return name def _dedupe_material(material): """Reuse an already-loaded material with the same base name. Importing many split Shapespark glTFs shares image datablocks by filepath, but still creates `branch-01.001`, `branch-01.002`, ... material copies. Those names would make the Cesium exporter bake duplicate export materials, so collapse them back to the first material for each base name. """ name = _base_name(material.name) existing = bpy.data.materials.get(name) if existing and existing is not material: return existing material.name = name if getattr(material, "blend_method", "OPAQUE") == "BLEND": material.blend_method = "HASHED" material.alpha_threshold = ALPHA_CUTOFF material.show_transparent_back = False return material # -------------------------------------------------------------------------- # loader — returns [] when its model is absent, so a clean checkout still builds # and the caller falls back to procedural trees def _load_shapespark(): """Shapespark low-poly plant kit: 12 alpha-cut tree variants. The split assets keep one glTF/bin per plant and a shared texture directory. Each variant is imported once, baked into a mesh datablock, then its source object is discarded; individual OSM trees link those datablocks. """ variants = [] before_materials = set(bpy.data.materials) before_images = set(bpy.data.images) paths = [ os.path.join(SHAPESPARK_DIR, tree_id, "model.gltf") for tree_id in SHAPESPARK_TREE_IDS ] if not all(os.path.exists(path) for path in paths): return [] for tree_id, path in zip(SHAPESPARK_TREE_IDS, paths): before_objects = set(bpy.data.objects) try: bpy.ops.import_scene.gltf(filepath=path) except RuntimeError: return [] imported = [obj for obj in set(bpy.data.objects) - before_objects if obj.type == "MESH"] if not imported: return [] meshes = [] for index, obj in enumerate(sorted(imported, key=lambda item: item.name)): mesh = _bake(obj, "Shapespark_%s_%02d" % (tree_id, index)) for slot, material in enumerate(mesh.materials): if material is not None: mesh.materials[slot] = _dedupe_material(material) meshes.append(mesh) height, base_z = _measure(meshes) variants.append(TreeVariant(meshes, height, base_z)) _discard(imported) _purge_orphans(before_materials, before_images) return variants LOADERS = { "shapespark": _load_shapespark, } # The styles this module can serve, for the CLI to validate against. MODEL_STYLES = tuple(LOADERS) def assemble(positions, collection, style="shapespark"): """Place instanced trees of `style` at `positions`. positions is a list of (x, y, height) tuples as gathered by the two tree-collecting loops (point nodes + tree_row samples). `height` is the OSM height where tagged and a constant default otherwise, which means every sample along one tree_row arrives with an identical value — the per-index jitter below is what stops a row of forty from reading as one tree stamped forty times. Returns the number of trees placed, or 0 when the style's model is absent or unknown, which is the caller's signal to fall back to procedural trees. """ loader = LOADERS.get(style) if loader is None: return 0 variants = loader() if not variants: return 0 for index, (x, y, target_height) in enumerate(positions): variant = variants[int(((index * GOLDEN_TURN) % 1.0) * len(variants))] # Irrational periods stand in for an RNG: no repeat over any realistic # tree count, and a pure function of the index, so rebuilding an area # plants the identical forest. scale_wobble = 1.0 + SCALE_JITTER * math.sin(index * 2.399963) target = max(MIN_TREE_HEIGHT, target_height) * scale_wobble factor = target / variant.height yaw = ((index * GOLDEN_TURN) % 1.0) * math.tau tilt_x = TILT_JITTER * math.sin(index * 1.114517) tilt_y = TILT_JITTER * math.cos(index * 0.927295) # Scaled and negated, the variant's own ground line drops the trunk # onto z=0 whatever the source file used as its origin. z = -variant.base_z * factor for slot, mesh in enumerate(variant.meshes): obj = bpy.data.objects.new( "Tree_%s_%04d_%d" % (style, index, slot), mesh) obj.location = (x, y, z) obj.scale = (factor, factor, factor) obj.rotation_euler = (tilt_x, tilt_y, yaw) collection.objects.link(obj) tris = 0 for variant in variants: for mesh in variant.meshes: mesh.calc_loop_triangles() tris += len(mesh.loop_triangles) print("Tree style %r: %d variants, %d tris per instance, %d planted" % (style, len(variants), tris // max(1, len(variants)), len(positions))) return len(positions)