The site exporter now splits sidewalks and kerbs into ~20 m pieces (SEGMENTED / SEGMENT_M in Art/blender/site/export_site_fbx.py), 56 slices in all, so each has a bounding box that means something where it stands instead of reporting the highest point on a 279 m slope. They import and stage (63 site meshes in the demo map). The hypothesis behind it is dead, and that is the useful part: with the invisible sidewalk stopgap retired, the player now falls to Road_Hollywood at 366 - the slices do not block either. So the 279 m aspect ratio was never the cause; a 20 m slice of the same mesh fails identically. The sidewalk geometry itself is what does not collide, and the next place to look is its shape - an open surface with no volume, inverted winding or zero-thickness triangles - not the import flags and not the size. The stopgap retirement stands: the player walks on the road, which is real geometry that does collide, rather than on an invisible ledge.
131 lines
5.7 KiB
Python
131 lines
5.7 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 _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()
|