"""Export the site meshes from the assembled site .blend, one FBX per object. blender -b --factory-startup Art/blender/site/site-2026-11-06.blend \ --python Art/blender/site/export_site_fbx.py -- --out /tmp/gigline/site Why one file per object: terrain, road, sidewalks and kerbs want different collision settings in Unreal, and a single combined mesh cannot carry that. Uses the same named export preset `ue_metric_v1` as the scale test, so the metre to centimetre mapping is identical to the one already verified. """ import argparse import os import sys import bpy sys.path.insert(0, os.path.join(os.path.join(os.path.dirname(__file__), "..", "..", ".."), "Tools")) import fbx_export_preset as fbx_preset # noqa: E402 MESHES = ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North", "Kerb_South", "Kerb_North", "Crossing_Markings") # Ground surfaces are exported in slices. One 279 m slab cannot report an honest height: # its bounding box is the highest point anywhere along the street, which is 2.2 m above the # surface at the player's position, and that misled every placement decision in the demo. # A 20 m slice has a bounding box that means something where it stands. SEGMENTED = ("Sidewalk_North", "Sidewalk_South", "Kerb_North", "Kerb_South") SEGMENT_M = 20.0 def _segment(obj, name, out_dir, length_m): """Export `obj` as slices of `length_m` along X, each with its own honest bbox.""" xs = [(obj.matrix_world @ v.co).x for v in obj.data.vertices] x0, x1 = min(xs), max(xs) count = max(1, int(round((x1 - x0) / length_m))) step = (x1 - x0) / count bpy.context.view_layer.objects.active = obj for index in range(count): a = x0 + index * step b = a + step bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) bpy.ops.object.duplicate() piece = bpy.context.active_object bpy.context.view_layer.objects.active = piece bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") # Cut at both boundaries and keep only the middle: faces spanning a boundary are # split cleanly rather than dropped, which a face-centre filter would do. bpy.ops.mesh.bisect(plane_co=(a, 0.0, 0.0), plane_no=(1.0, 0.0, 0.0), clear_inner=True) bpy.ops.mesh.bisect(plane_co=(b, 0.0, 0.0), plane_no=(1.0, 0.0, 0.0), clear_outer=True) bpy.ops.object.mode_set(mode="OBJECT") piece.name = f"{name}_{index:02d}" zs = [(piece.matrix_world @ v.co).z for v in piece.data.vertices] path = os.path.join(out_dir, f"{piece.name}.fbx") fbx_preset.export_static(path, selection=True) print(f"[site] {piece.name:22} x {a:+.1f}..{b:+.1f} m " f"z {min(zs):+.2f}..{max(zs):+.2f} m -> {path}") bpy.data.objects.remove(piece, do_unlink=True) # The context loses its active object when the duplicate goes; restore the source so # the next iteration's operators have something to act on. bpy.context.view_layer.objects.active = obj obj.select_set(True) def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] ap = argparse.ArgumentParser() ap.add_argument("--out", default="/tmp/gigline/site") args = ap.parse_args(argv) os.makedirs(args.out, exist_ok=True) print(f"[site] scene objects: {len(bpy.data.objects)}") found = [] for name in MESHES: obj = bpy.data.objects.get(name) if obj is None or obj.type != "MESH": print(f"[site] MISSING {name}") continue found.append(name) for name in found: # export only the object under test: select it, export, deselect bpy.ops.object.select_all(action="DESELECT") obj = bpy.data.objects[name] obj.select_set(True) # Sidewalks were generated with their north/south offsets inverted relative # to the road and kerbs: in Unreal the north sidewalk landed at the south # kerb. Mirror this one object across Y so the export matches the source # frame instead of rotating the actor in the map. mirrored = False # the south sidewalk is correct in the .blend; see street_mesh if mirrored: obj.scale.y *= -1.0 bpy.context.view_layer.objects.active = obj bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) # Object mode has no mesh context; flip winding through the mesh data. me = obj.data for poly in me.polygons: verts = list(poly.vertices) verts.reverse() poly.vertices = verts me.update() _ = bpy.context.view_layer bpy.context.view_layer.objects.active = obj if name in SEGMENTED: _segment(obj, name, args.out, SEGMENT_M) continue path = os.path.join(args.out, name + ".fbx") fbx_preset.export_static(path, selection=True) if mirrored: # The flip is an export correction, not a change to the source: undo it. obj.scale.y *= -1.0 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) for poly in obj.data.polygons: verts = list(poly.vertices) verts.reverse() poly.vertices = verts obj.data.update() zs = [(obj.matrix_world @ v.co).z for v in obj.data.vertices] print(f"[site] {name:18} verts {len(obj.data.vertices):6} z {min(zs):+.2f}..{max(zs):+.2f} m " f"-> {path}") print(f"[site] exported {len(found)} of {len(MESHES)} meshes") main()