Two findings, one a bug and one the real cause. Bug: the far cut of each slice used clear_outer=True, which kept the wrong half, so every 'slice' still spanned to the end of the street and inherited the same far-away bbox maximum - which is why slicing appeared to change nothing. Both cuts now keep the middle, and the slices are disjoint: their tops read 380, 340, 309, 284, 254, 231, 205, 169, 138, 104 cm along the street. Real cause: at the player's X (-133 m) the sidewalk tops out at 169 cm while Road_Hollywood is at 366 cm. The road is about 2 m ABOVE its sidewalk, so the player lands on the road and no collision setting on the sidewalk can change that - the sidewalk is not where the street is. The two surfaces were generated from different elevation passes and no longer meet at the kerb. That is site-pipeline data, not placement. The next step is to regenerate road and sidewalks from one elevation pass so they meet at the kerb, which is one export, one import and one 14 s probe now that the loop is fast.
161 lines
7.2 KiB
Python
161 lines
7.2 KiB
Python
"""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 _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()
|