The road was never mis-modelled: a 279 m strip imports with ONE auto-generated convex hull, and a convex hull's top is the strip's maximum height. A downward trace therefore landed at 366 cm - a flat lid about 2 m above the visible road, which sits at roughly 152 cm there. That single fact explains every failed sidewalk fix, the 'road above its own sidewalk' reading, and why the player always ended up on Road_Hollywood. Slicing the road the way the sidewalks already were fixes it, and the cross-section is now continuous: Terrain_DEM 154 Sidewalk_North_07 158 Road_Hollywood_07 158 (was 366) Two changes had to land together: the road exported in slices, and the map stopped staging the single Road_Hollywood asset - which still sat on top of the slices and won every trace until removed. All 23 captures regenerated; the player and the car both stand on a continuous ground plane.
166 lines
7.6 KiB
Python
166 lines
7.6 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.
|
|
# 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()
|