gameplay: designated street crossing — zebra markings, corridor cut, up-facing street
- street_mesh.py: continental zebra markings (0.5 m bars, 0.5 m gaps, 3 m band) at all three signalised crossings, triangles not quads (FBX SDK refuses to triangulate the crown-twisted quads), stripes +6 cm above the DEM so they stay above the road surface; winding flipped so every street surface faces up (road/sidewalks/markings were 100% down-facing and invisible in engine once the terrain stopped covering them). - terrain_from_dem.py: cut a 10.7 m corridor around the Hollywood Blvd centreline so road, kerbs and sidewalks are not buried under the terrain; sidewalks themselves cover the transition band, walk-check stays at 0 holes. - Blender builders save with a writability guard + fatal failure: *.blend is LFS-lockable and checks out read-only; a failed save previously left downstream steps building from stale files. - ue_import_site.py: Crossing_Markings imports with no collision (paint on the road); ue_build_demo_map.py stages it and chmods the lockable .umap before saving (save_map silently returned False on read-only maps). - ue_build_review_map.py: demo-crossing camera moved to the real crossing (s=130.1 m -> UE -24291,-1340) and fixed the ensure_writable path (was missing the .umap suffix, so the chmod never fired).
This commit is contained in:
1 parent
985dfcbac7
commit
bc2e421d33
20 files changed
+133
-36
No files matched your search
@@ -17,7 +17,7 @@ import sys
|
||||
import bpy
|
||||
|
||||
MESHES = ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North",
|
||||
"Kerb_South", "Kerb_North")
|
||||
"Kerb_South", "Kerb_North", "Crossing_Markings")
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Binary file not shown.
@@ -63,6 +63,7 @@ APPEND = {
|
||||
"Art/blender/terrain/terrain-2026-11-06.blend": ["Terrain_DEM"],
|
||||
"Art/blender/street/street-2026-11-06.blend": [
|
||||
"Road_Hollywood", "Sidewalk_South", "Sidewalk_North", "Kerb_South", "Kerb_North",
|
||||
"Crossing_Markings",
|
||||
],
|
||||
}
|
||||
BLOCKOUT = "Art/blender/blockout/blockout-2026-11-06.blend"
|
||||
@@ -193,7 +194,14 @@ def main():
|
||||
|
||||
out = os.path.abspath(args.out)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=out)
|
||||
# *.blend is LFS-lockable, so a fresh checkout marks it read-only. Make it
|
||||
# writable again or the save fails and downstream steps build from a stale
|
||||
# file — which is worse than failing here.
|
||||
if os.path.exists(out) and not os.access(out, os.W_OK):
|
||||
os.chmod(out, 0o644)
|
||||
result = bpy.ops.wm.save_as_mainfile(filepath=out)
|
||||
if result != {"FINISHED"}:
|
||||
raise RuntimeError(f"could not save {out}: {result}")
|
||||
print(f"saved {out}")
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -148,7 +148,10 @@ def strip(rows, offsets, z_at, name, material):
|
||||
for r in range(len(rows) - 1):
|
||||
for c in range(cols - 1):
|
||||
i = r * cols + c
|
||||
faces.append((i, i + 1, i + cols + 1, i + cols))
|
||||
# Winding faces up: the natural (i, i+1, i+cols+1, i+cols) order
|
||||
# produces downward normals (every surface faced the ground and was
|
||||
# invisible in engine until the terrain corridor cut exposed it).
|
||||
faces.append((i, i + cols, i + cols + 1, i + 1))
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
mesh.from_pydata(verts, [], faces)
|
||||
mesh.update()
|
||||
@@ -239,7 +242,7 @@ def main():
|
||||
verts.extend([(x, y, z_road), (x, y, z_walk)])
|
||||
for idx in range(len(rows) - 1):
|
||||
i = idx * 2
|
||||
faces.append((i, i + 2, i + 3, i + 1))
|
||||
faces.append((i, i + 1, i + 3, i + 2))
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
mesh.from_pydata(verts, [], faces)
|
||||
mesh.update()
|
||||
@@ -259,24 +262,34 @@ def main():
|
||||
paint.diffuse_color = (0.85, 0.85, 0.85, 1.0)
|
||||
|
||||
def crossing_band(s_c):
|
||||
# Stripes are emitted as two triangles each: the road crown makes the
|
||||
# quad non-planar and the FBX SDK refuses to triangulate it on import
|
||||
# ("unable to triangulate mesh" — the mesh is omitted entirely).
|
||||
zone = 3.0 # band length along the road, m
|
||||
w, g = 0.5, 0.5 # stripe width / gap, m
|
||||
span = (SOUTH_GUTTER + 0.2, NORTH_GUTTER - 0.2)
|
||||
verts, faces = [], []
|
||||
n = int(zone // (w + g))
|
||||
covered = n * w + (n - 1) * g # actual stripe coverage, centred on s_c
|
||||
for k in range(n):
|
||||
s0 = s_c - zone / 2 + k * (w + g)
|
||||
s0 = s_c - covered / 2 + k * (w + g)
|
||||
corners = []
|
||||
base_idx = len(verts) # verts grows only after the stripe is sampled
|
||||
for s_edge in (s0, s0 + w):
|
||||
base, normal, _ = at(s_edge)
|
||||
for off in span:
|
||||
x = base[0] + off * normal[0]
|
||||
y = base[1] + off * normal[1]
|
||||
corners.append((len(verts), (x, y, (dem_z(x, y) or 0.0) + 0.03)))
|
||||
# +6 cm above the DEM = 4 cm above the road surface: the road
|
||||
# interpolates the DEM along 1 m rows while the markings sample
|
||||
# only their own corners, so a 1 cm lift left parts of the
|
||||
# stripes sunk under the road and invisible in game.
|
||||
corners.append((base_idx + len(corners), (x, y, (dem_z(x, y) or 0.0) + 0.06)))
|
||||
# corners order: (s0,off_s), (s0,off_n), (s1,off_s), (s1,off_n)
|
||||
idx = [c[0] for c in corners]
|
||||
verts.extend(c[1] for c in corners)
|
||||
faces.append((idx[0], idx[1], idx[3], idx[2]))
|
||||
faces.append((idx[0], idx[3], idx[1]))
|
||||
faces.append((idx[0], idx[2], idx[3]))
|
||||
mesh = bpy.data.meshes.new("Crossing_Markings")
|
||||
mesh.from_pydata(verts, [], faces)
|
||||
mesh.update()
|
||||
@@ -303,7 +316,14 @@ def main():
|
||||
|
||||
out = os.path.abspath(args.out)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=out)
|
||||
# *.blend is LFS-lockable, so a fresh checkout marks it read-only. Make it
|
||||
# writable again or the save fails and downstream steps build from a stale
|
||||
# file — which is worse than failing here.
|
||||
if os.path.exists(out) and not os.access(out, os.W_OK):
|
||||
os.chmod(out, 0o644)
|
||||
result = bpy.ops.wm.save_as_mainfile(filepath=out)
|
||||
if result != {"FINISHED"}:
|
||||
raise RuntimeError(f"could not save {out}: {result}")
|
||||
print(f"saved {out}")
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -53,6 +53,9 @@ def parse_args():
|
||||
ap.add_argument("--ymax", type=float, default=110.0)
|
||||
ap.add_argument("--osm", default=None,
|
||||
help="OSM extract; building outlines are cut out so rooftops never become terrain")
|
||||
ap.add_argument("--corridor", type=float, default=10.7,
|
||||
help="half-width in m of the Hollywood Blvd corridor to cut out so the "
|
||||
"street meshes (road, kerbs, sidewalks) are not buried under the terrain")
|
||||
return ap.parse_args(argv)
|
||||
|
||||
|
||||
@@ -110,6 +113,30 @@ def pts_flat(ring):
|
||||
return ring
|
||||
|
||||
|
||||
def centreline_polyline(osm_path):
|
||||
"""Hollywood Blvd centreline as a local-frame polyline (Highland -> Orange)."""
|
||||
nodes, ways = oc.load(osm_path)
|
||||
line, _, _, _, _ = oc.block_extent(
|
||||
nodes, ways, "Hollywood Boulevard", "North Orange Drive", "North Highland Avenue")
|
||||
utm = project_to_utm(line, find_proj())
|
||||
return [(e - ORIGIN_E, n - ORIGIN_N) for e, n in utm]
|
||||
|
||||
|
||||
def dist_to_polyline(x, y, pts):
|
||||
best = float("inf")
|
||||
for (ax, ay), (bx, by) in zip(pts, pts[1:]):
|
||||
dx, dy = bx - ax, by - ay
|
||||
l2 = dx * dx + dy * dy
|
||||
if l2 == 0.0:
|
||||
continue
|
||||
t = max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / l2))
|
||||
px, py = ax + t * dx, ay + t * dy
|
||||
d = (x - px) ** 2 + (y - py) ** 2
|
||||
if d < best:
|
||||
best = d
|
||||
return best ** 0.5
|
||||
|
||||
|
||||
def inside(x, y, ring):
|
||||
"""Ray casting point-in-polygon."""
|
||||
hit = False
|
||||
@@ -146,9 +173,12 @@ def main():
|
||||
f"X {args.xmin}..{args.xmax}, Y {args.ymin}..{args.ymax}")
|
||||
|
||||
polys = []
|
||||
corridor = None
|
||||
if args.osm:
|
||||
polys = building_polygons(args.osm)
|
||||
print(f"masking {len(polys)} building footprints")
|
||||
corridor = centreline_polyline(args.osm)
|
||||
print(f"cutting {args.corridor} m corridor around the Hollywood Blvd centreline")
|
||||
|
||||
verts = []
|
||||
ok = []
|
||||
@@ -168,6 +198,10 @@ def main():
|
||||
verts.append((x, y, 0.0))
|
||||
ok.append(False)
|
||||
continue
|
||||
if corridor is not None and dist_to_polyline(x, y, corridor) <= args.corridor:
|
||||
verts.append((x, y, 0.0))
|
||||
ok.append(False)
|
||||
continue
|
||||
z = values[ri * width + ci]
|
||||
if nodata is not None and z == nodata:
|
||||
verts.append((x, y, 0.0))
|
||||
@@ -212,7 +246,14 @@ def main():
|
||||
|
||||
out = os.path.abspath(args.out)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=out)
|
||||
# *.blend is LFS-lockable, so a fresh checkout marks it read-only. Make it
|
||||
# writable again or the save fails and downstream steps build from a stale
|
||||
# file — which is worse than failing here.
|
||||
if os.path.exists(out) and not os.access(out, os.W_OK):
|
||||
os.chmod(out, 0o644)
|
||||
result = bpy.ops.wm.save_as_mainfile(filepath=out)
|
||||
if result != {"FINISHED"}:
|
||||
raise RuntimeError(f"could not save {out}: {result}")
|
||||
print(f"saved {out}")
|
||||
|
||||
|
||||
|
||||
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.
@@ -15,6 +15,8 @@
|
||||
# Every actor is (re)created by label, so rerunning the script rebuilds the map
|
||||
# deterministically instead of stacking duplicates.
|
||||
|
||||
import os
|
||||
|
||||
import unreal
|
||||
|
||||
MAP = "/Game/Hollywood/DemoMap"
|
||||
@@ -83,7 +85,7 @@ def build():
|
||||
# --- site meshes, if they have been imported --------------------------
|
||||
imported = 0
|
||||
for name in ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North",
|
||||
"Kerb_South", "Kerb_North"):
|
||||
"Kerb_South", "Kerb_North", "Crossing_Markings"):
|
||||
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
|
||||
if mesh is None:
|
||||
continue
|
||||
@@ -103,6 +105,12 @@ def build():
|
||||
unreal.Rotator(pitch=0.0, yaw=0.0, roll=0.0))
|
||||
log("staged: Demo_PlayerStart on the north sidewalk facing south")
|
||||
|
||||
# A lockable LFS .umap checks out read-only, which makes save_map silently
|
||||
# return False — chmod u+w first or the staged actors never persist.
|
||||
umap = os.path.join(unreal.Paths.project_dir(), MAP.replace("/Game/", "Content/") + ".umap")
|
||||
if os.path.exists(umap) and not os.access(umap, os.W_OK):
|
||||
os.chmod(umap, 0o644)
|
||||
log(f"map writable: chmod u+w {umap}")
|
||||
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
||||
unreal.EditorLevelLibrary.get_editor_world(), MAP)
|
||||
log(f"save_map {MAP}: {saved}")
|
||||
|
||||
@@ -29,7 +29,7 @@ def ensure_writable(path):
|
||||
# A packed .umap left read-only (git sets 644->444 on checkout, or the
|
||||
# editor re-flags it) makes save_map silently return False with no error.
|
||||
# chmod u+w it so the staged camera list and actors actually persist.
|
||||
p = os.path.join(unreal.Paths.project_dir(), path.replace("/Game/", "Content/"))
|
||||
p = os.path.join(unreal.Paths.project_dir(), path.replace("/Game/", "Content/") + ".umap")
|
||||
try:
|
||||
mode = os.stat(p).st_mode & 0o777
|
||||
if mode & 0o200 == 0:
|
||||
@@ -229,7 +229,7 @@ try:
|
||||
# the colonnade's underside.
|
||||
("demo-spawn-view", unreal.Vector(-10722.5, -3500, 165), unreal.Vector(-10722.5, -1450, 700)),
|
||||
("demo-street-level", unreal.Vector(-11000, -400, 165), unreal.Vector(-10722.5, -1450, 500)),
|
||||
("demo-crossing", unreal.Vector(-13600, -1650, 165), unreal.Vector(-12722.5, -1450, 500)),
|
||||
("demo-crossing", unreal.Vector(-23750, -1800, 480), unreal.Vector(-24291, -1400, 300)),
|
||||
# the two north-side background buildings (TCL, Tussaud's) seen from
|
||||
# the south sidewalk, across Hollywood Blvd: their frontage and mass.
|
||||
("demo-north-buildings", unreal.Vector(-22800, -1000, 400), unreal.Vector(-22800, 3000, 300)),
|
||||
|
||||
+18
-4
@@ -27,7 +27,10 @@ def log(msg):
|
||||
unreal.log(str(msg))
|
||||
|
||||
|
||||
def import_fbx(path, name, collision):
|
||||
def import_fbx(path, name, collision="complex"):
|
||||
# 5.8 note: there is no FBX import 'no collision' switch exposed to Python;
|
||||
# a default import simply generates no collision primitives, so collision="none"
|
||||
# means: skip the trace-flag step below and leave the body setup empty.
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = path
|
||||
task.destination_path = DEST
|
||||
@@ -58,10 +61,16 @@ def import_fbx(path, name, collision):
|
||||
# something to stand on. Terrain and street get complex-as-simple: a simple
|
||||
# hull would ramp straight over the kerbs.
|
||||
#
|
||||
# UE 5.8 exposes this only through StaticMeshEditorSubsystem (the mesh itself
|
||||
# UE 5.8 exposes this only through the body setup's trace flag (the mesh itself
|
||||
# has no trace-flag property, and the subsystem has no setter in Python), so
|
||||
# the import path sets the body setup's trace flag through the mesh's
|
||||
# collision options at import, then verifies it by reading it back.
|
||||
# the import path sets the body setup's trace flag, then verifies it by reading
|
||||
# it back.
|
||||
if collision == "none":
|
||||
box = mesh.get_bounding_box()
|
||||
mn, mx = box.min, box.max
|
||||
log(f"{name:18} import ok size=({mx.x - mn.x:.0f}, {mx.y - mn.y:.0f}, {mx.z - mn.z:.0f}) cm "
|
||||
f"min_z={mn.z:.1f} collision=none")
|
||||
return
|
||||
trace = (unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE
|
||||
if collision == "complex" else unreal.CollisionTraceFlag.CTF_USE_SIMPLE_AND_COMPLEX)
|
||||
try:
|
||||
@@ -83,6 +92,11 @@ def main():
|
||||
return
|
||||
for path in files:
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
# Crossing markings are paint on the road surface — no collision at all,
|
||||
# so the player never stands on a 1 cm lip at the band edges.
|
||||
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"
|
||||
import_fbx(path, stem, collision)
|
||||
|
||||
Reference in new issue
Block a user