Files
gig-line/Art/blender/site/export_site_fbx.py
T
zwitschi 1916321761 feat(site): slice the ground into 20 m segments - and the slice hypothesis dies with them
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.
2026-11-09 18:13:29 +01:00

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()