Files
gig-line/Art/blender/site/export_site_fbx.py
T
zwitschi d0d87e8fda fix(site): the slice cuts now keep the middle, and the surfaces do not agree
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.
2026-11-09 18:19:39 +01:00

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