"""Build a textured, stylized city level from OpenStreetMap data. Run: Blender -b -P tools/build_level_osm.py -- \ [spawn_lat,lon,bearing] [rp_lat,lon;...] \ [bridge_way_name|-] [canopy_way_name|-] Buildings extrude with FLUX facade textures (box-projected UVs) and get a glazed shopfront band, a cantilevered awning over the footpath and a roof parapet. Roads get dashed centre lines and zebra crossings; the OSM footway network becomes raised chamfered footpaths. Trees are trunk + layered canopy (figs and palms), scattered along streets on top of the mapped ones. Street lamps, benches, bins, bollards and bus shelters come straight off OSM nodes. An optional named way grows a steel cantilever truss (hello Story Bridge); another grows Queen Street Mall's winged canopies. Meshes suffixed -col/-convcol get Godot collision on import. All variation is seeded off OSM ids, so a rebuild is byte-for-byte reproducible. ponytail: flat terrain -- no DEM yet, so bridges sit at water level and the Kangaroo Point cliffs are walls beside the road rather than a drop. Data (c) OpenStreetMap contributors, ODbL. """ import json import math import os import random import sys import bpy import bmesh from mathutils import Matrix, Vector 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 "" bridge_name = argv[6] if len(argv) > 6 and argv[6] != "-" else "" canopy_name = argv[7] if len(argv) > 7 and argv[7] != "-" else "" TEX = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets", "textures") M_LAT = 110574.0 M_LON = 111320.0 * math.cos(math.radians(LAT0)) GROUND_FLOOR = 4.2 # height of the glazed shopfront band AWNING_Z = 3.75 # underside of the street awning AWNING_OUT = 2.3 # how far it cantilevers over the footpath PATH_H = 0.14 # footpath height above the road PATH_CHAMFER = 0.45 # horizontal run of the kerb ramp -- keeps cars off a hard edge MAX_TREES = 1400 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) # ---------------------------------------------------------------- materials def _finish(mat, rough, metal=0.0): bsdf = next(n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED") bsdf.inputs["Roughness"].default_value = rough bsdf.inputs["Metallic"].default_value = metal # explicit: GLTF defaults read as glossy return bsdf def tex_material(name, img, rough=0.85, metal=0.0): mat = bpy.data.materials.new(name) mat.use_nodes = True bsdf = _finish(mat, rough, metal) tex = mat.node_tree.nodes.new("ShaderNodeTexImage") tex.image = bpy.data.images.load(os.path.join(TEX, img)) mat.node_tree.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) return mat def flat_material(name, rgba, rough=0.85, metal=0.0, emit=0.0): mat = bpy.data.materials.new(name) mat.use_nodes = True bsdf = _finish(mat, rough, metal) bsdf.inputs["Base Color"].default_value = rgba if emit: # signal lenses read as lit even in a shaded street canyon bsdf.inputs["Emission Color"].default_value = rgba bsdf.inputs["Emission Strength"].default_value = emit return mat M_GLASS = tex_material("facade_glass", "facade_glass.jpg", 0.28, 0.15) M_CONC = tex_material("facade_concrete", "facade_concrete.jpg") M_BRICK = tex_material("facade_brick", "facade_brick.jpg") M_HERIT = tex_material("facade_heritage", "facade_heritage.jpg") M_SAND = tex_material("facade_sandstone", "sandstone.jpg") M_SHOP = tex_material("shopfront", "shopfront.jpg", 0.25, 0.1) M_ROAD = tex_material("asphalt", "asphalt.jpg", 0.92) M_MALL = tex_material("pavers", "pavers.jpg", 0.88) M_PATH = tex_material("footpath", "footpath.jpg", 0.9) M_GRASS = tex_material("grass", "grass.jpg", 0.95) M_WATER = tex_material("water", "water.jpg", 0.12) M_ROCK = tex_material("rock", "rock.jpg", 0.95) M_STEEL = tex_material("steel", "steel.jpg", 0.45, 0.6) M_GROUND = tex_material("concrete_ground", "concrete_ground.jpg", 0.92) M_ROOF = tex_material("roofdeck", "roof_deck.jpg", 0.8) AWNINGS = [tex_material("awning_green", "awning_green.jpg", 0.9), tex_material("awning_grey", "awning_grey.jpg", 0.9), tex_material("awning_cream", "awning_cream.jpg", 0.9)] M_PLANTER = tex_material("planter", "planter.jpg", 0.75, 0.35) M_FOLIAGE = tex_material("foliage", "foliage.jpg", 0.95) M_BARK = tex_material("bark", "bark.jpg", 0.95) M_PANEL = tex_material("panel_white", "panel_white.jpg", 0.4, 0.25) M_LINE = flat_material("roadline", (0.88, 0.86, 0.78, 1), 0.75) M_DARK = flat_material("darkmetal", (0.13, 0.14, 0.15, 1), 0.55, 0.5) M_BUSRED = tex_material("asphalt_red", "asphalt_red.jpg", 0.92) # Brisbane's red bus lanes M_JAC = tex_material("foliage_jacaranda", "foliage_jacaranda.jpg", 0.95) M_BOUG = tex_material("bougainvillea", "bougainvillea.jpg", 0.95) M_WEATHER = tex_material("weatherboard", "weatherboard.jpg") M_CORRO = tex_material("corrugated_iron", "corrugated_iron.jpg", 0.55, 0.4) M_DECO = tex_material("facade_deco", "facade_deco.jpg") M_GLASS2 = tex_material("facade_glass2", "facade_glass2.jpg", 0.32, 0.12) M_SIGN = tex_material("signage_strip", "signage_strip.jpg", 0.7) BILLBOARDS = [tex_material("billboard_ad", "billboard_ad.jpg", 0.7), tex_material("billboard_ad2", "billboard_ad2.jpg", 0.7), tex_material("billboard_ad3", "billboard_ad3.jpg", 0.7)] M_LENS_R = flat_material("lens_red", (0.95, 0.10, 0.06, 1), 0.35, emit=2.5) M_LENS_A = flat_material("lens_amber", (0.98, 0.62, 0.05, 1), 0.35, emit=2.0) M_LENS_G = flat_material("lens_green", (0.10, 0.90, 0.35, 1), 0.35, emit=2.2) M_WIRE = flat_material("wire", (0.09, 0.09, 0.10, 1), 0.7) M_POLE = flat_material("powerpole", (0.36, 0.28, 0.19, 1), 0.9) FACADES = (M_BRICK, M_CONC, M_HERIT, M_SAND, M_DECO) QLD_TAGS = ("house", "detached", "terrace", "bungalow", "semidetached_house", "residential") # ------------------------------------------------------------ mesh helpers def uv_project(bm, scale): """Box-project UVs. `scale` is metres-per-tile: a float, or {mat_index: float}.""" bm.normal_update() # fresh faces have zero normals -> wrong projection axis uv = bm.loops.layers.uv.new("UVMap") for f in bm.faces: s = scale[f.material_index] if isinstance(scale, dict) else scale n = f.normal ax = max(range(3), key=lambda i: abs(n[i])) for l in f.loops: c = l.vert.co if ax == 2: l[uv].uv = (c.x / s, c.y / s) elif ax == 0: l[uv].uv = (c.y / s, c.z / s) else: l[uv].uv = (c.x / s, c.z / s) def add_obj(name, bm, mats, uv_scale, smooth=False): if not bm.faces: bm.free() return None if smooth: # every primitive is built with its own verts, so a batched canopy mesh # exports ~6x the vertices it needs; welding them also rounds the shading bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=0.002) for f in bm.faces: f.smooth = True uv_project(bm, uv_scale) mesh = bpy.data.meshes.new(name) bm.to_mesh(mesh) bm.free() for m in mats: mesh.materials.append(m) obj = bpy.data.objects.new(name, mesh) bpy.context.collection.objects.link(obj) return obj def quad(bm, pts, mat=0): """Face from 4 points (tuples or Vectors), wound as given.""" try: f = bm.faces.new([bm.verts.new(Vector(p)) for p in pts]) f.material_index = mat return f except ValueError: return None def slab(bm, top, thick, mat=0, side_mat=None): """Thin box from 4 top-face corners, extruded downward by `thick`.""" side_mat = mat if side_mat is None else side_mat t = [Vector(p) for p in top] b = [p - Vector((0, 0, thick)) for p in t] quad(bm, t, mat) quad(bm, list(reversed(b)), mat) for i in range(4): j = (i + 1) % 4 quad(bm, [b[i], b[j], t[j], t[i]], side_mat) def _paint(verts, mat): """Material-index the faces a primitive op just made. Blender 5's create_* ops return only 'verts', and the new verts link to exactly the new faces.""" if mat: for f in {f for v in verts for f in v.link_faces}: f.material_index = mat def beam(bm, a, b, w, h, mat=0): """Oriented box from point a to point b (Vectors).""" axis = Vector(b) - Vector(a) L = axis.length if L < 0.05: return quat = axis.to_track_quat("X", "Z") m = Matrix.Translation((Vector(a) + Vector(b)) / 2) @ quat.to_matrix().to_4x4() verts = bmesh.ops.create_cube(bm, size=1.0)["verts"] bmesh.ops.scale(bm, vec=(L, w, h), verts=verts) bmesh.ops.transform(bm, matrix=m, verts=verts) _paint(verts, mat) def cone(bm, at, r1, r2, depth, segments=7, mat=0): verts = bmesh.ops.create_cone(bm, cap_ends=True, segments=segments, radius1=r1, radius2=r2, depth=depth)["verts"] bmesh.ops.translate(bm, vec=at, verts=verts) _paint(verts, mat) return verts def blob(bm, at, radii, subdiv=1, mat=0): """Squashed icosphere -- the tree canopy primitive.""" try: verts = bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0)["verts"] except TypeError: # Blender < 4.0 verts = bmesh.ops.create_icosphere(bm, subdivisions=subdiv, diameter=2.0)["verts"] bmesh.ops.scale(bm, vec=radii, verts=verts) bmesh.ops.translate(bm, vec=at, verts=verts) _paint(verts, mat) def ring_ccw(pts): """Open vertex ring wound counter-clockwise (so edge normals point outward).""" r = pts[:-1] if len(pts) > 2 and pts[0] == pts[-1] else list(pts) area = sum(r[i][0] * r[(i + 1) % len(r)][1] - r[(i + 1) % len(r)][0] * r[i][1] for i in range(len(r))) return r if area > 0 else list(reversed(r)) def poly_bm(pts, z, h=0.0): """Filled (optionally extruded) polygon bmesh from closed pt list.""" bm = bmesh.new() verts = [bm.verts.new((x, y, z)) for x, y in pts[:-1]] face = bm.faces.new(verts) if h > 0.0: 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) return bm def ribbon(bm, pts, half, z, mat=0, drop=None, offset=0.0): """Strip of width 2*half along a polyline, shifted sideways by `offset`. `drop` chamfers the edges down to z-PATH_H.""" 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, dx / L if offset: x1, y1 = x1 + nx * offset, y1 + ny * offset x2, y2 = x2 + nx * offset, y2 + ny * offset if drop is None: quad(bm, [(x1 + nx * half, y1 + ny * half, z), (x1 - nx * half, y1 - ny * half, z), (x2 - nx * half, y2 - ny * half, z), (x2 + nx * half, y2 + ny * half, z)], mat) else: inner = max(half - PATH_CHAMFER, half * 0.35) for s0, s1, z0, z1 in ((half, inner, z - drop, z), (inner, -inner, z, z), (-inner, -half, z, z - drop)): quad(bm, [(x1 + nx * s0, y1 + ny * s0, z0), (x1 + nx * s1, y1 + ny * s1, z1), (x2 + nx * s1, y2 + ny * s1, z1), (x2 + nx * s0, y2 + ny * s0, z0)], mat) # ------------------------------------------------------------------- props def add_tree(fol, jac, frond, trunk, x, y, rng): """Fig (broad layered canopy), jacaranda (same shape, purple), or palm.""" if rng.random() < 0.18: h = rng.uniform(6.5, 9.5) cone(trunk, (x, y, h / 2), 0.22, 0.14, h, 7) for i in range(7): a = i * math.tau / 7 + rng.uniform(-0.25, 0.25) reach = rng.uniform(1.8, 2.5) tip = Vector((x + math.cos(a) * reach, y + math.sin(a) * reach, h - rng.uniform(0.8, 1.6))) beam(frond, Vector((x, y, h)), tip, 0.7, 0.12) else: # canopy well clear of the roof line of a car, and built from three # offset spheres so the silhouette breaks up instead of reading as a ball clear = rng.uniform(3.6, 4.8) r = rng.uniform(1.7, 2.5) cone(trunk, (x, y, (clear + 1.0) / 2), 0.20, 0.13, clear + 1.0, 7) target = jac if rng.random() < 0.22 else fol # jacaranda season is eternal here for i in range(3): off = Vector((rng.uniform(-1, 1), rng.uniform(-1, 1), 0)) * r * 0.42 blob(target, (x + off.x, y + off.y, clear + r * (0.45 + 0.34 * i)), (r * rng.uniform(0.75, 1.05), r * rng.uniform(0.75, 1.05), r * rng.uniform(0.55, 0.75)), subdiv=2) def add_bench(bm, x, y, rng): a = rng.random() * math.tau d = Vector((math.cos(a), math.sin(a), 0)) p = Vector((-d.y, d.x, 0)) for s in (-0.75, 0.75): c = Vector((x, y, 0)) + d * s beam(bm, c + Vector((0, 0, 0.02)), c + Vector((0, 0, 0.45)), 0.36, 0.07) a0 = Vector((x, y, 0.47)) - d * 0.95 slab(bm, [(a0 + p * 0.28).to_tuple(), (a0 - p * 0.28).to_tuple(), (a0 - p * 0.28 + d * 1.9).to_tuple(), (a0 + p * 0.28 + d * 1.9).to_tuple()], 0.08) def add_signal(bm, lens, x, y, rng): """Aussie traffic signal: pole, lantern head, mast arm with a second head. Lenses go in their own bmesh so they can be emissive.""" h = 3.9 cone(bm, (x, y, h / 2), 0.10, 0.08, h, 6) a = rng.random() * math.tau d = Vector((math.cos(a), math.sin(a), 0)) p = Vector((-d.y, d.x, 0)) for at, top in ((Vector((x, y, 0)) + p * 0.22, h - 0.15), (Vector((x, y, 0)) + d * 3.6, h + 0.55)): # lantern housing + three stacked lenses beam(bm, at + Vector((0, 0, top - 1.0)), at + Vector((0, 0, top)), 0.34, 0.34) for i, m in enumerate((0, 1, 2)): c = at + Vector((0, 0, top - 0.22 - i * 0.32)) # must clear beam()'s 0.05 m minimum length or the lens silently vanishes beam(lens, c + d * 0.15, c + d * 0.28, 0.20, 0.20, m) beam(bm, Vector((x, y, h + 0.4)), Vector((x, y, h + 0.4)) + d * 3.6, 0.13, 0.13) # mast arm def add_power_pole(bm, wire_bm, x, y, prev, rng): """Timber power pole with a crossarm; wires sag to the previous pole.""" h = 8.6 cone(bm, (x, y, h / 2), 0.19, 0.13, h, 6) beam(bm, (x - 0.95, y, h - 0.5), (x + 0.95, y, h - 0.5), 0.11, 0.11) tops = [Vector((x - 0.8, y, h - 0.38)), Vector((x, y, h - 0.05)), Vector((x + 0.8, y, h - 0.38))] if prev is not None: for a, b in zip(prev, tops): mid = (a + b) / 2 - Vector((0, 0, (a - b).length * 0.045)) # catenary sag beam(wire_bm, a, mid, 0.045, 0.045) beam(wire_bm, mid, b, 0.045, 0.045) return tops def add_shelter(bm, glassbm, x, y, rng): a = rng.random() * math.tau d = Vector((math.cos(a), math.sin(a), 0)) p = Vector((-d.y, d.x, 0)) c = Vector((x, y, 0)) for sd in (-1.8, 1.8): for sp in (-1.1, 1.1): f = c + d * sd + p * sp beam(bm, f, f + Vector((0, 0, 2.6)), 0.1, 0.1) top = [(c + d * s + p * q + Vector((0, 0, 2.72))).to_tuple() for s, q in ((-2.0, -1.3), (2.0, -1.3), (2.0, 1.3), (-2.0, 1.3))] slab(bm, top, 0.12) back = c + p * 1.1 quad(glassbm, [(back - d * 1.8).to_tuple(), (back + d * 1.8).to_tuple(), (back + d * 1.8 + Vector((0, 0, 2.4))).to_tuple(), (back - d * 1.8 + Vector((0, 0, 2.4))).to_tuple()]) def add_canopy(bm, at, fwd): """Queen Street Mall's winged shade canopies: splayed legs, folded wing plates.""" hub = Vector((at.x, at.y, 7.6)) side = Vector((-fwd.y, fwd.x, 0)).normalized() for s in (-1, 1): for f in (-1, 1): foot = Vector((at.x, at.y, 0)) + side * (s * 1.5) + fwd * (f * 1.5) beam(bm, foot, hub, 0.24, 0.24) for s in (-1, 1): root_a = hub + side * (s * 0.5) - fwd * 1.2 root_b = hub + side * (s * 0.5) + fwd * 1.2 mid_a = root_a + side * (s * 4.5) + Vector((0, 0, 1.5)) mid_b = root_b + side * (s * 4.5) + Vector((0, 0, 1.5)) tip_a = mid_a + side * (s * 4.0) + fwd * 1.1 + Vector((0, 0, -0.4)) tip_b = mid_b + side * (s * 4.0) - fwd * 1.1 + Vector((0, 0, -0.4)) slab(bm, [root_a.to_tuple(), root_b.to_tuple(), mid_b.to_tuple(), mid_a.to_tuple()], 0.18) slab(bm, [mid_a.to_tuple(), mid_b.to_tuple(), tip_b.to_tuple(), tip_a.to_tuple()], 0.16) beam(bm, hub, tip_a, 0.12, 0.12) beam(bm, hub, tip_b, 0.12, 0.12) # ------------------------------------------------------------------- build d = json.load(open(src)) counts = dict.fromkeys( ("bld", "road", "path", "park", "planter", "water", "cliff", "tree", "prop", "cross", "signal", "pole", "pp", "sl", "bo", "wb", "drag_m"), 0) GREEN_LEISURE = ("park", "recreation_ground", "pitch", "playground", "golf_course", "nature_reserve") GREEN_LANDUSE = ("grass", "recreation_ground", "village_green", "meadow", "forest") PLANTER_LEISURE = ("garden",) SKIP_HW = ("steps", "corridor", "platform", "elevator", "proposed", "construction") ROAD_W = {"motorway": 11.0, "trunk": 10.0, "primary": 9.0, "secondary": 8.0, "tertiary": 7.0, "pedestrian": 16.0, "residential": 6.5, "busway": 7.0} PATH_W = {"footway": 2.6, "path": 2.2, "cycleway": 2.4, "pedestrian": 4.0} # Baked decorative props. Lamps, bollards and bins are deliberately absent: # they're the things you clip at speed, so they become markers that game.gd # fills with topple-able RigidBodies instead of static scenery. PROPS = {"bench": add_bench} SMASHABLE = {"street_lamp": "SL", "bollard": "BO", "waste_basket": "WB"} fol_bm, frond_bm, trunk_bm = bmesh.new(), bmesh.new(), bmesh.new() prop_bm, glass_bm = bmesh.new(), bmesh.new() awn_bm, path_bm, line_bm = bmesh.new(), bmesh.new(), bmesh.new() road_bm = {"road": bmesh.new(), "mall": bmesh.new(), "bus": bmesh.new()} # batched: 385 road objects was 385 draw calls sign_bm, jac_bm = bmesh.new(), bmesh.new() bill_bm = [bmesh.new(), bmesh.new(), bmesh.new()] # one per ad design sig_bm, lens_bm = bmesh.new(), bmesh.new() pole_bm, wire_bm = bmesh.new(), bmesh.new() pp_spots, wb_spots = [], [] # (x, y, yaw): parked-car / wheelie-bin markers for game.gd to fill smash_spots = {"SL": [], "BO": [], "WB": []} # street lamps / bollards / bins off OSM drag_candidates = [] # wide road polylines; the straightest run becomes the drag strip PP_CAP, WB_CAP = 70, 40 SMASH_CAP = {"SL": 90, "BO": 90, "WB": 60} PARK_STREETS = ("residential", "tertiary", "secondary", "unclassified") tree_sites = [] # (x, y, seed) -- mapped trees first, then scattered road_ways = [] # (pts, width) for street-tree scattering for e in d["elements"]: tags = e.get("tags", {}) rng = random.Random(e["id"]) if e["type"] == "node": px, py = xy(e["lat"], e["lon"]) kind = (tags.get("natural") or tags.get("highway") or tags.get("amenity") or tags.get("barrier") or "") if kind == "tree": tree_sites.append((px, py, e["id"])) elif kind == "bus_stop": add_shelter(prop_bm, glass_bm, px, py, rng) counts["prop"] += 1 elif kind == "traffic_signals": add_signal(sig_bm, lens_bm, px, py, rng) counts["signal"] += 1 elif kind in SMASHABLE: smash_spots[SMASHABLE[kind]].append((px, py, rng.random() * math.tau)) elif kind in PROPS: PROPS[kind](prop_bm, px, py, rng) counts["prop"] += 1 continue geom = e.get("geometry") if not geom: continue pts = [xy(g["lat"], g["lon"]) for g in geom] closed = len(pts) > 3 and geom[0] == geom[-1] if tags.get("building"): if not closed or tags["building"] in ("roof", "bridge"): continue 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 ring = ring_ccw(pts) if len(ring) < 3: continue bt = tags["building"] qld = bt in QLD_TAGS if qld: # Queenslander: low weatherboard box under corrugated iron, no # shopfront band, no parapet -- houses have eaves, not parapets h = 4.2 + (e["id"] % 3) * 0.6 fac = M_WEATHER elif bt in ("tower", "office", "hotel", "apartments") and h > 30 or h > 55: fac = M_GLASS if e["id"] % 3 else M_GLASS2 elif h < 16 and bt in ("retail", "commercial", "yes"): fac = (M_HERIT, M_BRICK, M_SAND)[e["id"] % 3] else: fac = FACADES[e["id"] % 5] bm = bmesh.new() gf = 0.0 if qld else min(GROUND_FLOOR, h * 0.45) for i in range(len(ring)): (x1, y1), (x2, y2) = ring[i], ring[(i + 1) % len(ring)] if gf > 0.0: quad(bm, [(x1, y1, 0), (x2, y2, 0), (x2, y2, gf), (x1, y1, gf)], 2) quad(bm, [(x1, y1, gf), (x2, y2, gf), (x2, y2, h), (x1, y1, h)], 0) try: bm.faces.new([bm.verts.new((x, y, h)) for x, y in ring]).material_index = 1 except ValueError: pass if not qld: for i in range(len(ring)): # parapet caps the roof edge (x1, y1), (x2, y2) = ring[i], ring[(i + 1) % len(ring)] beam(bm, (x1, y1, h + 0.45), (x2, y2, h + 0.45), 0.35, 0.9, 0) if h > 26: # rooftop plant room cx = sum(p[0] for p in ring) / len(ring) cy = sum(p[1] for p in ring) / len(ring) beam(bm, (cx - 2.5, cy, h + 1.4), (cx + 2.5, cy, h + 1.4), 4.0, 2.8, 1) bmesh.ops.triangulate(bm, faces=bm.faces) add_obj("bld_%d-col" % e["id"], bm, [fac, M_CORRO if qld else M_ROOF, M_SHOP], {0: 13.0 if not qld else 6.5, 1: 9.0 if not qld else 3.0, 2: 4.6}) counts["bld"] += 1 # rooftop billboard on a slice of the mid-rises, along the longest edge if not qld and 12.0 < h < 40.0 and rng.random() < 0.16: bi = max(range(len(ring)), key=lambda i: math.dist(ring[i], ring[(i + 1) % len(ring)])) (ex1, ey1), (ex2, ey2) = ring[bi], ring[(bi + 1) % len(ring)] eL = math.dist((ex1, ey1), (ex2, ey2)) if eL > 8.0: mx, my = (ex1 + ex2) / 2, (ey1 + ey2) / 2 ux, uy = (ex2 - ex1) / eL, (ey2 - ey1) / eL half = min(4.0, eL * 0.35) a = Vector((mx - ux * half, my - uy * half, h + 2.6)) b = Vector((mx + ux * half, my + uy * half, h + 2.6)) bb = bill_bm[e["id"] % 3] beam(bb, a, b, 0.25, 3.0) beam(bb, Vector((mx, my, h)), Vector((mx, my, h + 1.4)), 0.3, 0.3) # street awning over the footpath -- not every shop has one, and a whole # street at one height in one colour reads as a painted stripe if not qld and 6.0 < h < 45.0 and rng.random() < 0.7: az = AWNING_Z + rng.uniform(-0.35, 0.5) amat = e["id"] % 3 out = AWNING_OUT * rng.uniform(0.85, 1.1) for i in range(len(ring)): (x1, y1), (x2, y2) = ring[i], ring[(i + 1) % len(ring)] dx, dy = x2 - x1, y2 - y1 L = math.hypot(dx, dy) if L < 4.0: continue ox, oy = dy / L * out, -dx / L * out slab(awn_bm, [(x1, y1, az + 0.35), (x2, y2, az + 0.35), (x2 + ox, y2 + oy, az), (x1 + ox, y1 + oy, az)], 0.14, amat) # shop fascia signs above the awning, proud of the facade sx, sy = dy / L * 0.07, -dx / L * 0.07 quad(sign_bm, [(x1 + sx, y1 + sy, az + 0.42), (x2 + sx, y2 + sy, az + 0.42), (x2 + sx, y2 + sy, az + 1.15), (x1 + sx, y1 + sy, az + 1.15)]) elif tags.get("natural") == "water" or tags.get("water"): if not closed: continue try: bm = poly_bm(pts, -0.35) except Exception: continue # ponytail: water is solid (-col) so cars skim it instead of falling forever add_obj("water_%d-col" % e["id"], bm, [M_WATER], 30.0) counts["water"] += 1 elif tags.get("leisure") in PLANTER_LEISURE: if not closed or len(pts) < 4: continue ring = ring_ccw(pts) bm = bmesh.new() for i in range(len(ring)): # raised corten planter box (x1, y1), (x2, y2) = ring[i], ring[(i + 1) % len(ring)] quad(bm, [(x1, y1, 0), (x2, y2, 0), (x2, y2, 0.5), (x1, y1, 0.5)], 0) try: bm.faces.new([bm.verts.new((x, y, 0.5)) for x, y in ring]).material_index = 1 except ValueError: bm.free() continue bmesh.ops.triangulate(bm, faces=bm.faces) add_obj("planter_%d" % e["id"], bm, [M_PLANTER, M_FOLIAGE], {0: 2.5, 1: 3.5}) counts["planter"] += 1 elif tags.get("leisure") in GREEN_LEISURE or tags.get("landuse") in GREEN_LANDUSE \ or tags.get("natural") in ("grassland", "scrub", "wood"): if not closed: continue try: bm = poly_bm(pts, 0.02) except Exception: continue add_obj("park_%d" % e["id"], bm, [M_GRASS], 14.0) counts["park"] += 1 elif tags.get("amenity") == "parking" and closed: # surface carparks: rows of parked-shitbox markers along the two longest # edges, inset off the aisle -- prime Crash Mode fodder ring = ring_ccw(pts) edges = sorted(range(len(ring)), key=lambda i: -math.dist(ring[i], ring[(i + 1) % len(ring)])) for bi in edges[:2]: (ex1, ey1), (ex2, ey2) = ring[bi], ring[(bi + 1) % len(ring)] eL = math.dist((ex1, ey1), (ex2, ey2)) if eL < 10.0: continue ux, uy = (ex2 - ex1) / eL, (ey2 - ey1) / eL yaw = math.atan2(uy, ux) + math.pi / 2 k = 3.5 while k < eL - 3.5 and len(pp_spots) < PP_CAP * 3: if rng.random() < 0.6: pp_spots.append((ex1 + ux * k - uy * 3.0, ey1 + uy * k + ux * 3.0, yaw)) k += 6.0 elif tags.get("natural") == "cliff": bm = bmesh.new() for i in range(len(pts) - 1): beam(bm, Vector((pts[i][0], pts[i][1], 7.0)), Vector((pts[i + 1][0], pts[i + 1][1], 7.0)), 2.0, 14.0) add_obj("cliff_%d-col" % e["id"], bm, [M_ROCK], 12.0) counts["cliff"] += 1 elif tags.get("highway"): hw = tags["highway"] if hw in SKIP_HW: continue try: # OSM layer is sometimes "0.5", or junk layer = float(tags.get("layer") or 0) except ValueError: layer = 0.0 if tags.get("tunnel") == "yes" or layer < 0: continue if hw in ("footway", "path", "cycleway"): if tags.get("footway") == "crossing" or tags.get("crossing"): # actual zebra bars -- one solid strip reads as a white slab for s in (-1.5, -0.75, 0.0, 0.75, 1.5): ribbon(line_bm, pts, 0.22, 0.045, offset=s) counts["cross"] += 1 else: ribbon(path_bm, pts, PATH_W.get(hw, 2.4) / 2, PATH_H, drop=PATH_H) counts["path"] += 1 continue wide = ROAD_W.get(hw, 6.0) rkey = "mall" if hw == "pedestrian" else ("bus" if hw == "busway" else "road") ribbon(road_bm[rkey], pts, wide / 2, 0.03) counts["road"] += 1 if hw in ("residential", "unclassified"): # timber power poles down one side with sagging wires -- the single # most Australian thing a suburban street can have prev = None acc = 20.0 side = 1 if (e["id"] % 2) else -1 for i in range(len(pts) - 1): (x1, y1), (x2, y2) = pts[i], pts[i + 1] segL = math.hypot(x2 - x1, y2 - y1) if segL < 0.5: continue ux, uy = (x2 - x1) / segL, (y2 - y1) / segL while acc < segL: off = (wide / 2 + 2.6) * side px_, py_ = x1 + ux * acc - uy * off, y1 + uy * acc + ux * off prev = add_power_pole(pole_bm, wire_bm, px_, py_, prev, rng) counts["pole"] += 1 acc += 38.0 acc -= segL if hw in PARK_STREETS: # kerbside parallel parking + the odd wheelie bin on the footpath acc = 14.0 for i in range(len(pts) - 1): (x1, y1), (x2, y2) = pts[i], pts[i + 1] segL = math.hypot(x2 - x1, y2 - y1) if segL < 0.5: continue ux, uy = (x2 - x1) / segL, (y2 - y1) / segL yaw = math.atan2(uy, ux) while acc < segL: side = 1 if rng.random() < 0.5 else -1 off = (wide / 2 - 1.1) * side bx, by = x1 + ux * acc, y1 + uy * acc if rng.random() < 0.45: pp_spots.append((bx - uy * off, by + ux * off, yaw)) if rng.random() < 0.3: boff = (wide / 2 + 1.5) * side wb_spots.append((bx - uy * boff, by + ux * boff, rng.random() * math.tau)) acc += 28.0 acc -= segL road_ways.append((pts, wide, hw)) if hw not in ("service", "busway") and wide >= 7.0: drag_candidates.append(pts) # no footpath strip on a pedestrian mall -- the mall paving IS the surface if hw != "pedestrian" and wide >= 7.0 and hw not in ("busway",): # dashed centre line for i in range(len(pts) - 1): (x1, y1), (x2, y2) = pts[i], pts[i + 1] L = math.hypot(x2 - x1, y2 - y1) for k in range(int(L / 9.0)): t0, t1 = (k * 9.0 + 1.5) / L, (k * 9.0 + 5.0) / L ribbon(line_bm, [(x1 + (x2 - x1) * t0, y1 + (y2 - y1) * t0), (x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1)], 0.09, 0.045) # street trees: OSM maps a fraction of what Brisbane actually has, so line the # kerbs of named streets and the mall edges with our own, deterministically for pts, wide, hw in road_ways: if hw in ("motorway", "trunk", "busway", "service"): continue step = 13.0 if hw == "pedestrian" else 24.0 off = wide / 2 - (2.6 if hw == "pedestrian" else -2.4) # mall trees line the edges 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 < step: continue nx, ny = -dy / L, dx / L for k in range(1, int(L / step)): t = k * step / L bx, by = x1 + dx * t, y1 + dy * t for s in (-1, 1): tree_sites.append((bx + nx * off * s, by + ny * off * s, hash((round(bx, 1), round(by, 1), s)) & 0x7fffffff)) seen = set() for tx, ty, seed in tree_sites: if counts["tree"] >= MAX_TREES: break key = (round(tx / 7.0), round(ty / 7.0)) # no two trees in one 7 m cell if key in seen: continue seen.add(key) add_tree(fol_bm, jac_bm, frond_bm, trunk_bm, tx, ty, random.Random(seed)) counts["tree"] += 1 add_obj("roads", road_bm["road"], [M_ROAD], 8.0) add_obj("mall_paving", road_bm["mall"], [M_MALL], 4.0) add_obj("busway", road_bm["bus"], [M_BUSRED], 8.0) add_obj("trees_foliage", fol_bm, [M_FOLIAGE], 0.9, smooth=True) add_obj("trees_jacaranda", jac_bm, [M_JAC], 0.9, smooth=True) add_obj("trees_fronds", frond_bm, [M_FOLIAGE], 0.9) add_obj("trees_trunks", trunk_bm, [M_BARK], 0.7, smooth=True) add_obj("shop_signs", sign_bm, [M_SIGN], 5.5) for i, bb in enumerate(bill_bm): add_obj("billboards_%d" % i, bb, [BILLBOARDS[i]], 8.0) add_obj("signals", sig_bm, [M_DARK], 1.4) add_obj("signal_lenses", lens_bm, [M_LENS_R, M_LENS_A, M_LENS_G], 1.0) add_obj("power_poles", pole_bm, [M_POLE], 1.6) add_obj("power_wires", wire_bm, [M_WIRE], 4.0) add_obj("props", prop_bm, [M_DARK], 1.6) add_obj("prop_glass", glass_bm, [M_GLASS], 3.0) add_obj("awnings", awn_bm, AWNINGS, 3.0) add_obj("footpaths-col", path_bm, [M_PATH], 3.5) add_obj("roadlines", line_bm, [M_LINE], 3.0) # steel truss along a named bridge way if bridge_name: cands = [e for e in d["elements"] if e.get("tags", {}).get("name") == bridge_name and e.get("geometry")] if cands: way = max(cands, key=lambda e: len(e["geometry"])) pts = [Vector((*xy(g["lat"], g["lon"]), 0.0)) for g in way["geometry"]] # arc-length resample the middle 65% (the steel section) dists = [0.0] for i in range(1, len(pts)): dists.append(dists[-1] + (pts[i] - pts[i - 1]).length) T = dists[-1] def at(s): for i in range(1, len(dists)): if dists[i] >= s: f = (s - dists[i - 1]) / max(dists[i] - dists[i - 1], 0.001) return pts[i - 1].lerp(pts[i], f) return pts[-1] s0, s1 = 0.175 * T, 0.825 * T N = 26 bm = bmesh.new() samples = [] for i in range(N + 1): t = i / N p = at(s0 + (s1 - s0) * t) h = 4.0 + 16.0 * (math.exp(-((t - 0.32) / 0.10) ** 2) + math.exp(-((t - 0.68) / 0.10) ** 2)) \ + 6.0 * math.sin(math.pi * t) fwd = (at(min(s0 + (s1 - s0) * t + 4, T)) - p) fwd.z = 0 fwd.normalize() samples.append((p, Vector((-fwd.y, fwd.x, 0)), h)) for side in (-1, 1): for i in range(N): p1, pe1, h1 = samples[i] p2, pe2, h2 = samples[i + 1] a = p1 + pe1 * side * 8.0 b = p2 + pe2 * side * 8.0 beam(bm, a + Vector((0, 0, h1)), b + Vector((0, 0, h2)), 0.7, 0.7) # top chord beam(bm, a + Vector((0, 0, 0.5)), b + Vector((0, 0, 0.5)), 0.5, 0.5) # bottom chord beam(bm, a + Vector((0, 0, 0.5)), a + Vector((0, 0, h1)), 0.5, 0.5) # vertical if i % 2 == 0: beam(bm, a + Vector((0, 0, 0.5)), b + Vector((0, 0, h2)), 0.4, 0.4) # diagonal for i in range(0, N + 1, 3): p, pe, h = samples[i] beam(bm, p - pe * 8.0 + Vector((0, 0, h)), p + pe * 8.0 + Vector((0, 0, h)), 0.5, 0.5) add_obj("bridge_steel-col", bm, [M_STEEL], 6.0) print("bridge truss built along %r (%.0fm span)" % (bridge_name, s1 - s0)) # Winged shade canopies along pedestrian ways whose name starts with the given # one -- OSM splits Queen Street Mall into a 15 m stub named "Queen Street Mall" # plus the 438 m pedestrian way that is just "Queen Street", so match the prefix. if canopy_name: ways = [e for e in d["elements"] if e.get("tags", {}).get("highway") == "pedestrian" and e.get("geometry") and e.get("tags", {}).get("name", "").startswith(canopy_name)] bm = bmesh.new() n = 0 for way in ways: pts = [Vector((*xy(g["lat"], g["lon"]), 0.0)) for g in way["geometry"]] acc = 22.0 for i in range(len(pts) - 1): seg = pts[i + 1] - pts[i] L = seg.length if L < 0.5: continue fwd = seg.normalized() while acc < L: add_canopy(bm, pts[i] + fwd * acc, fwd) acc += 48.0 n += 1 acc -= L if n: add_obj("mall_canopies-col", bm, [M_PANEL], 4.0) print("canopies built along %r (%d)" % (canopy_name, n)) else: bm.free() # ---- hero landmarks, keyed by level id ------------------------------------- if level_id == "southbank": # The Wheel of Brisbane: white steel, ~55 m, next to its mapped ticket office tick = [e for e in d["elements"] if "Wheel of Brisbane" in e.get("tags", {}).get("name", "") and e.get("geometry")] if tick: g = tick[0]["geometry"] cx = sum(xy(p["lat"], p["lon"])[0] for p in g) / len(g) + 12.0 cy = sum(xy(p["lat"], p["lon"])[1] for p in g) / len(g) + 8.0 bm = bmesh.new() R, HUB = 24.0, 30.0 N = 18 pts_w = [Vector((cx, cy + math.cos(i / N * math.tau) * R, HUB + math.sin(i / N * math.tau) * R)) for i in range(N)] hub = Vector((cx, cy, HUB)) for i in range(N): beam(bm, pts_w[i], pts_w[(i + 1) % N], 0.45, 0.45) # rim beam(bm, hub, pts_w[i], 0.28, 0.28) # spokes if i % 2 == 0: # gondolas p = pts_w[i] beam(bm, p + Vector((-0.8, 0, -1.6)), p + Vector((0.8, 0, -1.6)), 1.3, 1.5) for sy in (-7.0, 7.0): # A-frame legs for sx in (-4.0, 4.0): beam(bm, Vector((cx + sx, cy + sy, 0.0)), hub, 0.6, 0.6) beam(bm, Vector((cx - 4.5, cy, 0.6)), Vector((cx + 4.5, cy, 0.6)), 10.0, 1.2) # base add_obj("wheel_of_brisbane-col", bm, [M_PANEL], 5.0) print("wheel of brisbane at (%.0f, %.0f)" % (cx, cy)) # Grand Arbour: curling steel tendrils drowning in bougainvillea ways = [e for e in d["elements"] if e.get("tags", {}).get("name") == "Grand Arbour" and len(e.get("geometry") or []) >= 8] abm = bmesh.new() n_t = 0 for way in ways: wpts = [Vector((*xy(g["lat"], g["lon"]), 0.0)) for g in way["geometry"]] acc = 3.0 for i in range(len(wpts) - 1): seg = wpts[i + 1] - wpts[i] segL = seg.length if segL < 0.3: continue fwd = seg / segL perp = Vector((-fwd.y, fwd.x, 0)) while acc < segL: p = wpts[i] + fwd * acc arc = [p - perp * 2.0, p - perp * 1.3 + Vector((0, 0, 3.1)), p + perp * 0.4 + Vector((0, 0, 4.3)), p + perp * 1.8 + Vector((0, 0, 3.2))] for a, b in zip(arc, arc[1:]): beam(abm, a, b, 0.14, 0.14) if n_t % 2 == 0: blob(abm, (arc[2].x, arc[2].y, arc[2].z + 0.4), (1.4, 1.4, 0.9), mat=1) n_t += 1 acc += 5.0 acc -= segL if n_t: # steel tendrils (mat 0) + bougainvillea blobs (mat 1); no collision -- # slim posts as trimesh would tunnel weirdly at speed uv_project(abm, {0: 2.0, 1: 1.2}) mesh_a = bpy.data.meshes.new("arbour") abm.to_mesh(mesh_a) abm.free() mesh_a.materials.append(M_STEEL) mesh_a.materials.append(M_BOUG) obj_a = bpy.data.objects.new("arbour", mesh_a) bpy.context.collection.objects.link(obj_a) print("grand arbour tendrils: %d" % n_t) # ground slab sized to content (convex collider: huge trimesh tris break raycasts) 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() res = 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=res["verts"]) bmesh.ops.translate(bm, vec=(gx, gy, -0.5), verts=res["verts"]) add_obj("ground-convcol", bm, [M_GROUND], 18.0) # parked-car / wheelie-bin markers -> empties; game.gd fills them with # shuntable RigidBodies at load (a static GLB can't carry physics bodies) random.Random(1982).shuffle(pp_spots) smash_spots["WB"] += wb_spots # OSM street bins join the procedural wheelie bins batches = [("PP", pp_spots, PP_CAP)] + [(k, v, SMASH_CAP[k]) for k, v in smash_spots.items()] for prefix, spots, cap in batches: for i, (mx, my, yaw) in enumerate(spots[:cap]): emp = bpy.data.objects.new("%s_%03d" % (prefix, i), None) emp.location = (mx, my, 0.05) emp.rotation_euler = (0, 0, yaw) bpy.context.collection.objects.link(emp) counts[prefix.lower()] = min(len(spots), cap) # Drag strip: the longest genuinely straight run of wide road in the level. # Emitted as a marker pair so drag mode doesn't need hand-authored coordinates # per level -- a strip that bends is a strip you crash on rather than race. best_run = None for pts in drag_candidates: i = 0 while i < len(pts) - 1: ax, az = pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1] aL = math.hypot(ax, az) if aL < 1.0: i += 1 continue ax, az = ax / aL, az / aL j, run = i + 1, aL while j < len(pts) - 1: bx, bz = pts[j + 1][0] - pts[j][0], pts[j + 1][1] - pts[j][1] bL = math.hypot(bx, bz) if bL < 1.0 or (bx / bL) * ax + (bz / bL) * az < 0.985: # ~10 deg break run += bL j += 1 if best_run is None or run > best_run[0]: best_run = (run, pts[i], pts[j]) i = max(j, i + 1) if best_run and best_run[0] >= 150.0: run, p0, p1 = best_run yaw = math.atan2(p1[1] - p0[1], p1[0] - p0[0]) for nm, p in (("DragStart", p0), ("DragEnd", p1)): emp = bpy.data.objects.new(nm, None) emp.location = (p[0], p[1], 0.1) emp.rotation_euler = (0, 0, yaw) bpy.context.collection.objects.link(emp) counts["drag_m"] = int(run) 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)) 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) emp = bpy.data.objects.new("RP_%02d" % i, None) emp.location = (px, py, 0.1) bpy.context.collection.objects.link(emp) emp.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:", level_id, counts, "->", path)