diff --git a/blender/generate_scene.py b/blender/generate_scene.py index f8b8903..400b812 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -59,6 +59,7 @@ from osmassets.osm import Projector, parse_height, parse_osm # noqa: E402 from osmassets import water as _water # noqa: E402 from osmassets import grass as _grass # noqa: E402 from osmassets import scrub as _scrub # noqa: E402 +from osmassets import tree as _tree # noqa: E402 # Building assembly stays in this file because it needs make_prism, add_roof, @@ -91,6 +92,38 @@ def _assemble_building(ring, way_id, tag, args, buildings_c, building_mats): return 1, int(industrial), ring +def _add_polyhaven_trees(positions, collection): + """Scatter island_tree_01 instances at `positions`. + + positions is a list of (x, y, height) tuples as gathered by the two + tree-collecting loops (point nodes + tree_row samples). + """ + variants = _tree.load_tree_variants() + if not variants: + # Fall back to procedural so a missing model is not a hard build error. + print("Poly Haven tree model unavailable; switching to procedural trees") + add_tree_batch(positions, collection, + material_from_spec(catalog.MATERIALS["tree_trunk"]), + material_from_spec(catalog.MATERIALS["tree_crown"])) + return + + for index, (x, y, height) in enumerate(positions): + branch_mesh, leaf_mesh = variants[index % len(variants)] + target = max(4.0, height) + factor = target / 3.2 + + b_obj = bpy.data.objects.new(f"Tree_PH_{index:03d}_Branch", branch_mesh) + b_obj.location = (x, y, 0.0) + b_obj.scale = (factor, factor, factor) + b_obj.rotation_euler = (0, 0, (index * 1.61803398875) % 1.0 * math.tau) + collection.objects.link(b_obj) + + l_obj = bpy.data.objects.new(f"Tree_PH_{index:03d}_Leaf", leaf_mesh) + l_obj.location = (x, y, 0.0) + l_obj.scale = (factor, factor, factor) + l_obj.rotation_euler = (0, 0, (index * 1.61803398875) % 1.0 * math.tau) + collection.objects.link(l_obj) + MODEL_ROOT = os.path.abspath(os.path.join( os.path.dirname(__file__), "..", "assets", "models", "polyhaven" @@ -144,8 +177,8 @@ def cli_args(): values["office_overrides"] = set() else: values["office_overrides"] = set() - if values.get("tree_style") not in {"natural", "procedural"}: - raise RuntimeError("--tree-style must be 'natural' or 'procedural'") + if values.get("tree_style") not in {"natural", "procedural", "polyhaven"}: + raise RuntimeError("--tree-style must be 'natural', 'procedural', or 'polyhaven'") return values @@ -714,13 +747,17 @@ def build(args): trees.extend(row_samples) row_tree_count += len(row_samples) if trees: - tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"]) - if args.get("tree_style") == "natural": + tree_style = args.get("tree_style") + if tree_style == "polyhaven": + _add_polyhaven_trees(trees, props_c) + elif tree_style == "natural": + tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"]) add_natural_tree_instances( trees, props_c, tree_trunk, material_from_spec(catalog.MATERIALS["tree_crown_dark"]), material_from_spec(catalog.MATERIALS["tree_crown_light"])) else: + tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"]) add_tree_batch(trees, props_c, tree_trunk, material_from_spec(catalog.MATERIALS["tree_crown"])) @@ -794,7 +831,14 @@ def build(args): os.makedirs(os.path.dirname(args["output"]), exist_ok=True) os.makedirs(os.path.dirname(args["render"]), exist_ok=True) - bpy.ops.file.pack_all() + try: + bpy.ops.file.pack_all() + except RuntimeError: + # Poly Haven textures are resolved relative to the model directory + # and the blend source expects them under textures/ next to .blend. + # When they are absent the scene still saves — the tree meshes just + # render with missing-texture magenta. + print("Some external resources could not be packed; saving anyway") bpy.ops.wm.save_as_mainfile(filepath=args["output"]) bpy.ops.render.render(write_still=True) print("SCENE_DONE", json.dumps({"output": args["output"], diff --git a/blender/osmassets/tree.py b/blender/osmassets/tree.py new file mode 100644 index 0000000..caf4df8 --- /dev/null +++ b/blender/osmassets/tree.py @@ -0,0 +1,102 @@ +"""Import Poly Haven island_tree_01 as instanced-tree asset. + +This is the analog of load_tuft_variants() for trees. It appends only the +LOD1 meshes from the vendored .blend, which automatically pulls in their +material definitions and texture images. The top-level object parenting +(branch and leaf parts share one origin) is handled by the caller who +instances them as a single logical tree. +""" + +import os + +import bpy + + +TREE_BLEND = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", + "assets", "models", "polyhaven", "island_tree_01", + "island_tree_01_1k.blend", +) + +# Branch / leaf LOD1 pairings for the four variants. +# Each pairing is a single logical tree that the caller instances as one. +VARIANT_PAIRS = [("a", "b"), ("b", "a"), ("c", "a"), ("d", "b")] + + +def load_tree_variants(): + """Import the vendored Poly Haven tree and return meshes ready to instance. + + Returns a list of (branch_mesh, leaf_mesh) tuples, one per variant. The + meshes share the material datablocks that were appended alongside them, so + the textures and shader nodes are wired up automatically. + + Returns [] when the source .blend is absent so a clean checkout still + builds. The caller falls back to procedural trees. + """ + if not os.path.exists(TREE_BLEND): + return [] + + before_objects = set(bpy.data.objects) + before_meshes = set(bpy.data.meshes) + + # Gather the object names we need from the library. + wanted = [] + for branch_id, leaf_id in VARIANT_PAIRS: + wanted.append(f"island_tree_01_branches_{branch_id}_LOD1") + wanted.append(f"island_tree_01_leaves_{leaf_id}_LOD1") + + # bpy.ops.wm.append cannot handle a list of files in a single call + # reliably (it may crash when objects in the same library share data + # that was already linked by an earlier append in the same batch), so + # we append one at a time and skip the "already linked" warning. + for name in wanted: + try: + bpy.ops.wm.append( + filepath=TREE_BLEND + "/Object/" + name, + directory=TREE_BLEND + "/Object/", + files=[{"name": name}], + link=False, + ) + except RuntimeError: + # Object already linked by an earlier append of a sibling mesh + # that shared materials; this is expected and harmless. + pass + + imported = [obj for obj in set(bpy.data.objects) - before_objects + if obj.type == "MESH"] + + # Pair branch + leaf into variants. We append in the order above so + # a simple zip works. + variant_meshes = [] + for i in range(0, len(imported), 2): + if i + 1 >= len(imported): + break + # The import order is interleaved (branch, leaf, branch, leaf, …) + # because `wanted` alternated. Sort by name to be safe. + pass + + # Rebuild the pairs from the imported object names. + variants = [] + for branch_id, leaf_id in VARIANT_PAIRS: + b_obj = bpy.data.objects.get( + f"island_tree_01_branches_{branch_id}_LOD1") + l_obj = bpy.data.objects.get( + f"island_tree_01_leaves_{leaf_id}_LOD1") + if b_obj and l_obj: + # Copy the meshes so they survive object removal. + b_mesh = b_obj.data.copy() + b_mesh.name = "PolyHavenTree_Branch_" + branch_id + b_mesh.use_fake_user = True + l_mesh = l_obj.data.copy() + l_mesh.name = "PolyHavenTree_Leaf_" + leaf_id + l_mesh.use_fake_user = True + variants.append((b_mesh, l_mesh)) + + for obj in imported: + bpy.data.objects.remove(obj, do_unlink=True) + + tris = sum(len(bm.loop_triangles) + len(lm.loop_triangles) + for bm, lm in variants) + print(f"Poly Haven tree variants loaded: {len(variants)} " + f"({tris} tris per instance)") + return variants diff --git a/blender/tools/ingest_tree.py b/blender/tools/ingest_tree.py new file mode 100644 index 0000000..ba0e3e7 --- /dev/null +++ b/blender/tools/ingest_tree.py @@ -0,0 +1,138 @@ +"""Import Poly Haven island_tree_01 and export decimated GLBs. + +Each output GLB contains exactly one tree variant (1 branch mesh + 1 leaf +mesh) with shared material references so the main generator can instance them +the same way it instances grass tufts. +""" + +import math +import os +import sys + +import bpy + + +TREE_BLEND = os.path.join( + os.path.dirname(__file__), "..", "..", + "assets", "models", "polyhaven", "island_tree_01", + "island_tree_01_1k.blend", +) + +TARGET_BRANCH_TRIS = 180 +TARGET_LEAF_TRIS = 220 + +PAIRS = [("a", "b"), ("b", "a"), ("c", "a"), ("d", "b")] + + +def cli_args(): + values = {"out": None} + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + i = 0 + while i < len(argv): + if argv[i].startswith("--") and i + 1 < len(argv): + values[argv[i][2:]] = argv[i + 1] + i += 2 + else: + i += 1 + if not values.get("out"): + raise RuntimeError("--out is required") + return values + + +def decimate_to_target(obj, target_tris): + source = obj.data + source.calc_loop_triangles() + source_tris = len(source.loop_triangles) + if source_tris <= target_tris: + return source.copy() + modifier = obj.modifiers.new("TreeDecimate", "DECIMATE") + modifier.ratio = max(0.05, target_tris / source_tris) + bpy.context.view_layer.update() + depsgraph = bpy.context.evaluated_depsgraph_get() + evaluated = obj.evaluated_get(depsgraph) + result = bpy.data.meshes.new_from_object(evaluated) + result.calc_loop_triangles() + return result + + +def export_one(out_path, b_mesh, l_mesh, materials): + """Export a single tree variant to a clean GLB. + + Works in a fresh, empty scene so nothing from the source blend leaks in. + """ + scene = bpy.data.scenes.new("_TreeIngest") + bpy.context.window.scene = scene + col = bpy.data.collections.new("_Tree") + scene.collection.children.link(col) + + b_obj = bpy.data.objects.new("Branch", b_mesh) + l_obj = bpy.data.objects.new("Leaf", l_mesh) + col.objects.link(b_obj) + col.objects.link(l_obj) + + b_obj.data.materials.append(materials["branch"]) + l_obj.data.materials.append(materials["leaf"]) + l_obj.data.materials.append(materials["branch"]) + + scene.view_layers[0].update() + + bpy.ops.export_scene.gltf( + filepath=out_path, + export_format="GLB", + use_selection=False, + export_apply=False, + export_texcoords=True, + export_normals=True, + export_materials="EXPORT", + export_image_format="JPEG", + ) + + bpy.data.scenes.remove(scene, do_unlink=True) + + +def main(): + args = cli_args() + out_dir = args["out"] + os.makedirs(out_dir, exist_ok=True) + + if not os.path.exists(TREE_BLEND): + print(f"Missing: {TREE_BLEND}") + return 1 + + bpy.ops.wm.open_mainfile(filepath=TREE_BLEND) + + branch_mat = bpy.data.materials.get("island_tree_01_branches") + leaf_mat = bpy.data.materials.get("island_tree_01_leaves") + if not branch_mat or not leaf_mat: + return 1 + + decimated = [] + for vi, (branch_id, leaf_id) in enumerate(PAIRS): + branch_obj = bpy.data.objects.get( + f"island_tree_01_branches_{branch_id}_LOD1") + leaf_obj = bpy.data.objects.get( + f"island_tree_01_leaves_{leaf_id}_LOD1") + if not branch_obj or not leaf_obj: + continue + + b_mesh = decimate_to_target(branch_obj, TARGET_BRANCH_TRIS) + l_mesh = decimate_to_target(leaf_obj, TARGET_LEAF_TRIS) + decimated.extend([b_mesh, l_mesh]) + + out_path = os.path.join(out_dir, f"tree_variant_{vi:02d}.glb") + export_one(out_path, b_mesh, l_mesh, + {"branch": branch_mat, "leaf": leaf_mat}) + kb = os.path.getsize(out_path) // 1024 + print(f" variant_{vi:02d}: branch={len(b_mesh.loop_triangles)}tris " + f"leaf={len(l_mesh.loop_triangles)}tris ({kb}KB)") + + for mesh in decimated: + if mesh and mesh.name in bpy.data.meshes: + bpy.data.meshes.remove(mesh, do_unlink=True) + + print(f"\nDone — {len(PAIRS)} variants → {out_dir}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())