"""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. # The road is sliced for the same reason as the sidewalks: a 279 m strip imports with a single # convex hull whose top is the strip's MAXIMUM height, so a downward trace anywhere on it lands on # that hull - a flat lid 2 m above the visible surface. A 20 m slice has a hull that follows the # surface it belongs to. SEGMENTED = ("Sidewalk_North", "Sidewalk_South", "Kerb_North", "Kerb_South", "Road_Hollywood") SEGMENT_M = 20.0 def _ucx_box(name, x0, x1, y0, y1, z0, z1): """A thin convex box for a ground slice, named for Unreal's UCX convention.""" thickness = max(0.10, min(0.30, (z1 - z0) * 0.25)) cx, cy = (x0 + x1) * 0.5, (y0 + y1) * 0.5 cz = z1 - thickness * 0.5 bpy.ops.mesh.primitive_cube_add(size=1.0, location=(cx, cy, cz)) hull = bpy.context.active_object hull.name = f"UCX_{name}_01" hull.scale = ((x1 - x0) * 0.99, (y1 - y0) * 0.99, thickness) bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) return hull 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") # Two cuts, each with the normal pointing at the half to KEEP and clear_inner=True: # normal +X at a -> keeps x > a # normal -X at b -> keeps x < b # The first version used clear_outer for the far cut and produced nested slices that # each still spanned to the end of the street, which made every slice's bbox top out # at the same far-away maximum. bpy.ops.mesh.select_all(action="SELECT") 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.select_all(action="SELECT") bpy.ops.mesh.bisect(plane_co=(b, 0.0, 0.0), plane_no=(-1.0, 0.0, 0.0), clear_inner=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] # Explicit UCX box per slice. Neither the triangulated complex collision nor # complex-as-simple makes these meshes block in Unreal, so the collision is authored # here: one convex box per 20 m slice, thin, with its top at the slice's surface. xs_w = [(piece.matrix_world @ v.co).x for v in piece.data.vertices] ys_w = [(piece.matrix_world @ v.co).y for v in piece.data.vertices] hull = _ucx_box(piece.name, min(xs_w), max(xs_w), min(ys_w), max(ys_w), min(zs), max(zs)) path = os.path.join(out_dir, f"{piece.name}.fbx") bpy.ops.object.select_all(action="DESELECT") piece.select_set(True) hull.select_set(True) bpy.context.view_layer.objects.active = piece fbx_preset.export_static(path, selection=True) bpy.data.objects.remove(hull, do_unlink=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()