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.
This commit is contained in:
zwitschi committed 2026-11-09 18:13:29 +01:00
1 parent 1ee2a35d53
commit 1916321761
86 files changed
+275 -57

No files matched your search

+47
View File
@@ -22,6 +22,50 @@ 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 []
@@ -62,6 +106,9 @@ def main():
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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
View File
@@ -519,3 +519,10 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
- **Likely cause: the auto-generated simple collision for a 279 m x 8.6 m x 3.8 m slab is degenerate.** A convex hull over that aspect ratio either collapses or ends up far from the surface; the complex side is one-sided and the mesh may be wound the wrong way. `bOneSidedGeometry` no longer exists on `BodySetup` in 5.8, so the flag cannot be flipped from Python.
- **Working stopgap:** an invisible `279 x 8.6 x 0.08 m` blocking cube with its top at Z 380 in `Tools/ue_build_demo_map.py`. Verified: `top surface under player: Z=380 mesh=Cube`, `foot: actor Z 470, feet 382`.
- **The real fix belongs in the site export:** split the sidewalk into segments of a few metres, or export explicit `UCX_` boxes, so the collision is a set of sane hulls rather than one degenerate one.
## 2026-11-09 — Slicing the sidewalk did not fix its collision
- **The 20 m slice hypothesis is dead.** The site export now splits sidewalks and kerbs into ~20 m pieces (`Art/blender/site/export_site_fbx.py`, `SEGMENTED`, `SEGMENT_M`), so each has an honest per-slice bounding box. The slices still do **not** block: with the invisible stopgap retired, a downward trace goes straight to `Road_Hollywood` at 366 and the capsule lands there.
- **So the slab's 279 m aspect ratio was not the cause** of the missing collision — a 20 m slice of the same mesh fails identically. The mesh itself imports with collision that does not block, regardless of size.
- **What does collide:** `Road_Hollywood` (366) and the engine cube stopgap. What does not: every `Sidewalk_*` and `Kerb_*` slice.
- **Next place to look:** the sidewalk mesh's geometry - an open surface with no volume, inverted winding, or zero-thickness triangles - rather than the import flags or the size.
+16 -23
View File
@@ -500,6 +500,13 @@ def build():
roughness=0.72, specular=0.22, noise_scale=0.02,
roughness_variation=0.12),
}
# Segmented ground: the site export slices sidewalks and kerbs into 20 m pieces, so the
# map stages every slice. A single 279 m slab could not report an honest local height and
# its collision was degenerate; slices fix both.
segments = [f"Sidewalk_North_{i:02d}" for i in range(14)] + \
[f"Sidewalk_South_{i:02d}" for i in range(14)] + \
[f"Kerb_North_{i:02d}" for i in range(14)] + \
[f"Kerb_South_{i:02d}" for i in range(14)]
SURFACE_MATERIALS = {
"Terrain_DEM": "M_Ground",
"Road_Hollywood": "M_Asphalt",
@@ -514,6 +521,11 @@ def build():
for mat_name, spec in MATERIAL_SPECS.items():
built[mat_name] = ensure_material(mat_name, spec, textures)
for segment in segments:
if unreal.EditorAssetLibrary.does_asset_exist(f"/Game/Hollywood/Site/{segment}"):
SURFACE_MATERIALS[segment] = ("M_Concrete" if "Sidewalk" in segment
else "M_KerbConcrete")
imported = 0
for name, mat_name in SURFACE_MATERIALS.items():
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
@@ -681,29 +693,10 @@ def build():
# pavement top, so the capsule started inside the sidewalk block and the downward trace
# exited through it to Road_Hollywood at 366 - which is what made the character look like
# it floated while standing on a surface 14 cm above the road.
player_z = 469.0
# Stopgap collision for the north sidewalk. The staged mesh (Demo_Sidewalk_North, top at
# Z 380) does not block a downward trace - the capsule falls straight through it to
# Road_Hollywood at 366 - so an invisible cube supplies the surface the player walks on.
# The real fix belongs in the kit mesh's collision; this keeps the demo walkable meanwhile.
sidewalk_collision = spawn(unreal.StaticMeshActor,
unreal.Vector(-14000.0, -1482.5, 376.0),
"Demo_SidewalkCollision")
if sidewalk_collision:
cube = unreal.EditorAssetLibrary.load_asset("/Engine/BasicShapes/Cube.Cube")
comp = sidewalk_collision.get_component_by_class(unreal.StaticMeshComponent)
if cube and comp:
comp.set_static_mesh(cube)
comp.set_mobility(unreal.ComponentMobility.STATIC)
# A 100 cm engine cube: 279 m long, 8.6 m wide, 8 cm thick -> top at Z 380.
sidewalk_collision.set_actor_scale3d(unreal.Vector(279.0, 8.6, 0.08))
comp.set_collision_profile_name("BlockAll")
sidewalk_collision.set_actor_hidden_in_game(True)
loc = sidewalk_collision.get_actor_location()
scale = sidewalk_collision.get_actor_scale3d()
log(f"staged: Demo_SidewalkCollision at Z {loc.z:.0f}, scale Z {scale.z:.3f} "
f"(top {loc.z + 50.0 * scale.z:.0f} cm)")
player_z = 400.0
# The invisible sidewalk collision slab is retired: the site export now slices the
# sidewalks into 20 m pieces, so the walkable surface is real geometry with an honest
# local height instead of a 279 m slab whose bounding box was the highest point anywhere.
spawn(unreal.PlayerStart, unreal.Vector(-13336.0, -1150.0, player_z), "Demo_PlayerStart",
unreal.Rotator(pitch=0.0, yaw=90.0, roll=0.0))
log("staged: Demo_PlayerStart on the cut north sidewalk, facing south (+Y)")
+5 -2
View File
@@ -107,8 +107,11 @@ def main():
if stem == "Crossing_Markings":
import_fbx(path, stem, "none")
continue
collision = "complex" if stem in ("Terrain_DEM", "Road_Hollywood",
"Sidewalk_South", "Sidewalk_North") else "simple"
# Segmented ground surfaces keep their names with a numeric suffix; both the base
# names and the slices want the same walkable collision.
walkable = ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North",
"Kerb_South", "Kerb_North")
collision = "complex" if any(stem.startswith(w) for w in walkable) else "simple"
import_fbx(path, stem, collision)
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)