"""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 -- \ [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.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))