ShitboxInfinity/tools/build_level_osm.py
type-two d9b69e688e Brisbane: Queen Street Mall crash-run level from OpenStreetMap
tools/build_level_osm.py extrudes OSM buildings (-col), lays road strips,
Spawn + RacePath empties; osm_level_args.py computes a building-clear spawn on
a named way and race-loop corners from street intersection nodes. game.gd
builds Path3D from GLB RacePath empties, sanitizes spawn bases, and places
bodies BEFORE add_child. Three real bugs fixed: mall canopies (building=roof)
extruded as solid blocks, underground busways rendered at surface, and the
km-wide ground trimesh breaking suspension raycasts (now -convcol). Cars get
continuous_cd for thin building shells. tests/probe.gd is the physics
raycast debugger that found it.

Data (c) OpenStreetMap contributors, ODbL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 23:33:51 +10:00

167 lines
6.4 KiB
Python

"""Build a stylized city level from OpenStreetMap data (Overpass JSON, `out geom`).
Run: /Applications/Blender.app/Contents/MacOS/Blender -b -P tools/build_level_osm.py -- \
<overpass.json> <level_id> <origin_lat> <origin_lon> [spawn_lat,lon,bearing_deg] [rp_lat,lon;lat,lon;...]
Buildings extrude to building:levels height (else a hash-seeded CBD-ish height),
roads become dark strips, ground is one big slab. Meshes are suffixed -col so
Godot generates static collision on import. Spawn empty + RacePath empties
follow the game's level conventions. Exports levels/<level_id>/level.glb.
ponytail: flat terrain only -- cliffs/bridges are hand-built hero pieces later.
Data (c) OpenStreetMap contributors, ODbL.
"""
import json
import math
import os
import sys
import bpy
import bmesh
argv = sys.argv[sys.argv.index("--") + 1:]
src, level_id = argv[0], argv[1]
LAT0, LON0 = float(argv[2]), float(argv[3])
spawn_arg = argv[4] if len(argv) > 4 else ""
rp_arg = argv[5] if len(argv) > 5 else ""
M_LAT = 110574.0
M_LON = 111320.0 * math.cos(math.radians(LAT0))
def xy(lat, lon):
return ((lon - LON0) * M_LON, (lat - LAT0) * M_LAT)
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
def material(name, rgba, rough=0.8):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = next(n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED")
bsdf.inputs["Base Color"].default_value = rgba
bsdf.inputs["Roughness"].default_value = rough
return mat
PALETTE = [(0.75, 0.72, 0.66, 1), (0.62, 0.60, 0.58, 1), (0.55, 0.58, 0.62, 1),
(0.70, 0.63, 0.55, 1), (0.52, 0.55, 0.50, 1), (0.66, 0.66, 0.70, 1)]
MATS = [material("bld%d" % i, c) for i, c in enumerate(PALETTE)]
GLASS = material("bldglass", (0.35, 0.45, 0.52, 1), rough=0.3)
ROAD = material("road", (0.22, 0.22, 0.23, 1))
MALL = material("mall", (0.55, 0.45, 0.38, 1)) # paved pedestrian brown
GROUND = material("ground", (0.42, 0.42, 0.43, 1))
d = json.load(open(src))
buildings = 0
roads = 0
def add_mesh(name, mesh, mat):
obj = bpy.data.objects.new(name, mesh)
obj.data.materials.append(mat)
bpy.context.collection.objects.link(obj)
return obj
for e in d["elements"]:
tags = e.get("tags", {})
geom = e.get("geometry")
if not geom:
continue
pts = [xy(g["lat"], g["lon"]) for g in geom]
if tags.get("building"):
if len(pts) < 4:
continue
if tags["building"] in ("roof", "bridge"):
continue # mall canopies / walkway bridges become solid blocks, skip
try:
levels = float(tags.get("building:levels", "0"))
except ValueError:
levels = 0.0
h = levels * 3.4 if levels > 0 else 8.0 + (e["id"] % 11) * 3.5
bm = bmesh.new()
try:
verts = [bm.verts.new((x, y, 0)) for x, y in pts[:-1]]
face = bm.faces.new(verts)
res = bmesh.ops.extrude_face_region(bm, geom=[face])
up = [v for v in res["geom"] if isinstance(v, bmesh.types.BMVert)]
bmesh.ops.translate(bm, vec=(0, 0, h), verts=up)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bmesh.ops.triangulate(bm, faces=bm.faces)
mesh = bpy.data.meshes.new("bld")
bm.to_mesh(mesh)
mat = GLASS if h > 60 else MATS[e["id"] % len(MATS)]
add_mesh("bld_%d-col" % e["id"], mesh, mat)
buildings += 1
except Exception:
pass # self-intersecting footprint, skip
finally:
bm.free()
elif tags.get("highway"):
hw = tags["highway"]
if hw in ("footway", "steps", "path", "cycleway", "corridor", "platform"):
continue
if tags.get("tunnel") == "yes" or int(tags.get("layer", "0") or 0) < 0:
continue # underground busways/tunnels don't belong at street level
wide = {"primary": 9.0, "secondary": 8.0, "tertiary": 7.0, "pedestrian": 10.0}.get(hw, 6.0)
mat = MALL if hw == "pedestrian" else ROAD
bm = bmesh.new()
for i in range(len(pts) - 1):
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
dx, dy = x2 - x1, y2 - y1
L = math.hypot(dx, dy)
if L < 0.1:
continue
nx, ny = -dy / L * wide / 2, dx / L * wide / 2
try:
bm.faces.new([bm.verts.new(p) for p in [
(x1 + nx, y1 + ny, 0.03), (x1 - nx, y1 - ny, 0.03),
(x2 - nx, y2 - ny, 0.03), (x2 + nx, y2 + ny, 0.03)]])
except Exception:
pass
mesh = bpy.data.meshes.new("road")
bm.to_mesh(mesh)
bm.free()
add_mesh("road_%d" % e["id"], mesh, mat)
roads += 1
# ground slab sized to content
xs = [v.co.x for o in bpy.data.objects if o.type == "MESH" for v in o.data.vertices]
ys = [v.co.y for o in bpy.data.objects if o.type == "MESH" for v in o.data.vertices]
gx, gy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
bmesh.ops.scale(bm, vec=(max(xs) - min(xs) + 100, max(ys) - min(ys) + 100, 1.0), verts=bm.verts)
bmesh.ops.translate(bm, vec=(gx, gy, -0.5), verts=bm.verts)
mesh = bpy.data.meshes.new("ground")
bm.to_mesh(mesh)
bm.free()
# convcol: km-wide trimesh triangles break short suspension raycasts (float precision);
# a convex box collider is exact
add_mesh("ground-convcol", mesh, GROUND)
if spawn_arg:
slat, slon, sbear = [float(v) for v in spawn_arg.split(",")]
sx, sy = xy(slat, slon)
sp = bpy.data.objects.new("Spawn", None)
sp.location = (sx, sy, 0.1)
sp.rotation_euler = (0, 0, math.radians(-sbear)) # +Y = travel dir = compass bearing
bpy.context.collection.objects.link(sp)
if rp_arg:
rp = bpy.data.objects.new("RacePath", None)
bpy.context.collection.objects.link(rp)
for i, pair in enumerate(rp_arg.split(";")):
plat, plon = [float(v) for v in pair.split(",")]
px, py = xy(plat, plon)
e = bpy.data.objects.new("RP_%02d" % i, None)
e.location = (px, py, 0.1)
bpy.context.collection.objects.link(e)
e.parent = rp
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "levels", level_id)
os.makedirs(out, exist_ok=True)
for obj in bpy.data.objects:
obj.select_set(True)
path = os.path.join(out, "level.glb")
bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True)
print("level built: %d buildings, %d roads -> %s" % (buildings, roads, path))