ShitboxInfinity/tools/build_level_osm.py
m3ultra 939c2c5c35 Gateway Bridge: 64.5 m over the river, and a drag strip to match
The Sir Leo Hielscher Bridges and their industrial hinterland, from OSM
+ DEM like everything else -- but a 64.5 m concrete box girder split
across 13 OSM ways and twin carriageways broke every bridge assumption
the builder had, three different ways, before the right abstraction
appeared:

- Per-way sine humps (the Story Bridge recipe) turned each short
  approach span into a 52 m wall.
- A whole-bridge axis projection lifted named segments 3 km from the
  water and cliffed at every segment seam.
- The fix: deck height is a CONTINUOUS FIELD of distance-to-river --
  smoothstep from terrain to +57 m over the water. The river is the
  thing being bridged; let it set the profile. Seams are geometrically
  impossible because the field doesn't know what a segment is.

The bridge arg grew to Name:hump:style. Style "box" adds concrete
piers under every bridge-tagged span (28 of them), collision guardrails
chasing both deck edges (a 64 m deck with open edges is a cliff with
lane markings -- the first playtest fell off it), and puts Spawn,
DragStart/End and the RacePath on the deck field. Story Bridge keeps
its 16 m truss unchanged.

The payoff mode: a 1,422 m drag strip -- the longest in the game --
running up and over the bridge, verified in-shot racing Nanna's Morry
between the steel rails with the deck climbing ahead. Race mode's
auto-traced loop weaves interchange ramps and is honestly chaotic;
README flags the hand-authored-loop fix if bridge racing matters.

Plus the trace stitcher's loose-join pass (carriageways within 60 m
merge), a FLUX river-industrial sky, and the loose-join is what future
divided-road levels will lean on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:41:24 +10:00

1286 lines
60 KiB
Python

"""Build a textured, stylized city level from OpenStreetMap data.
Run: Blender -b -P tools/build_level_osm.py -- <overpass.json> <level_id> \
<origin_lat> <origin_lon> [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 arg: "Name" or "Name:hump_m" or "Name:hump_m:style" (truss|box).
# Story Bridge is "Bradfield Highway" (16 m hump, steel truss); the Gateway is
# "Gateway Motorway:52:box" -- a 64.5 m concrete box girder needs no cage.
_bridge_arg = argv[6] if len(argv) > 6 and argv[6] != "-" else ""
_bparts = _bridge_arg.split(":") if _bridge_arg else []
bridge_name = _bparts[0] if _bparts else ""
BRIDGE_HUMP = float(_bparts[1]) if len(_bparts) > 1 else 16.0
BRIDGE_STYLE = _bparts[2] if len(_bparts) > 2 else "truss"
canopy_name = argv[7] if len(argv) > 7 and argv[7] != "-" else ""
# 9th arg: raw-terrain texture. City levels keep concrete (urban ground between
# roads); "grass" also switches on forest fill -- bushland levels like Mt
# Coot-tha are mostly UNMAPPED green, and bald grey DEM reads as a moonscape.
TERRAIN_TEX = argv[8] if len(argv) > 8 and argv[8] not in ("", "-") else "concrete_ground"
FOREST_TREES = 2600 if TERRAIN_TEX == "grass" else 0
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))
# ---- real terrain (tools/fetch_dem.py output next to the area json) --------
import numpy as _np
DEM = None
DEM_META = None
_dem_base = src[:-5] if src.endswith(".json") else src
if os.path.exists(_dem_base + "_dem.f32"):
DEM_META = json.load(open(_dem_base + "_dem.json"))
DEM = _np.fromfile(_dem_base + "_dem.f32", dtype="<f4").reshape(
DEM_META["h"], DEM_META["w"])
print("DEM loaded: %dx%d, %.1f..%.1f m" % (DEM_META["w"], DEM_META["h"],
float(DEM.min()), float(DEM.max())))
def H(x, y):
"""Terrain height at level-local metres, bilinear. 0 when no DEM."""
if DEM is None:
return 0.0
lon = LON0 + x / M_LON
lat = LAT0 + y / M_LAT
u = (lon - DEM_META["min_lon"]) / (DEM_META["max_lon"] - DEM_META["min_lon"]) * (DEM_META["w"] - 1)
v = (DEM_META["max_lat"] - lat) / (DEM_META["max_lat"] - DEM_META["min_lat"]) * (DEM_META["h"] - 1)
u = min(max(u, 0.0), DEM_META["w"] - 1.001)
v = min(max(v, 0.0), DEM_META["h"] - 1.001)
x0, y0 = int(u), int(v)
fx, fy = u - x0, v - y0
a = DEM[y0, x0] * (1 - fx) + DEM[y0, x0 + 1] * fx
b = DEM[y0 + 1, x0] * (1 - fx) + DEM[y0 + 1, x0 + 1] * fx
return float(a * (1 - fy) + b * fy)
WATER_Z = 0.3 # river surface: above the riverbed's DEM zero, below banks
CARVE = [] # (x, y, h) of every draped road/path sample: terrain may not rise above these
_WATER_PTS = None # coarse point cloud of every water polygon
def _water_pts():
global _WATER_PTS
if _WATER_PTS is None:
_WATER_PTS = []
for e2 in d["elements"]:
t2 = e2.get("tags", {})
if (t2.get("natural") == "water" or t2.get("water")) and e2.get("geometry"):
geo = e2["geometry"]
for g in geo[::max(1, len(geo) // 80)]:
_WATER_PTS.append(xy(g["lat"], g["lon"]))
return _WATER_PTS
BRIDGE_R = 650.0 # how far from the water the big-deck climb begins
def named_deck_z(px4, py4):
"""Box-girder deck field: height rises with PROXIMITY TO THE RIVER, not
per way. OSM splits the Gateway into 13 ways across twin carriageways; a
per-way sine hump turned every short approach span into a 52 m wall, and
a whole-bridge axis projection lifted segments 3 km from the water. The
river is the thing being bridged -- let it set the profile: full hump over
the water, smoothstep down to terrain within BRIDGE_R, seam-free at every
segment boundary because the field is continuous in x,y."""
wp = _water_pts()
h = H(px4, py4)
if not wp:
return h
d2 = min((px4 - w[0]) ** 2 + (py4 - w[1]) ** 2 for w in wp)
t = 1.0 - min(math.sqrt(d2) / BRIDGE_R, 1.0)
s = t * t * (3.0 - 2.0 * t)
return max(h, h + BRIDGE_HUMP * s)
def bridge_profile(pts, named=False):
"""Deck heights for a bridge way: lerp between end terrain plus a hump, so
the deck spans the river instead of draping down into it. The named
bridge's hump comes from the arg (Story Bridge 16 m, Gateway 52 m)."""
cum = [0.0]
for i in range(1, len(pts)):
cum.append(cum[-1] + math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]))
T = max(cum[-1], 0.001)
hA, hB = H(*pts[0]), H(*pts[-1])
hump = BRIDGE_HUMP if named else 3.5
return [max(H(*pts[i]),
hA + (hB - hA) * (cum[i] / T) + hump * math.sin(math.pi * cum[i] / T))
for i in range(len(pts))]
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(TERRAIN_TEX, TERRAIN_TEX + ".jpg", 0.92)
M_CARPARK = tex_material("carpark_asphalt", "asphalt.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, drape=False):
"""Filled (optionally extruded) polygon bmesh from closed pt list.
drape=True adds terrain height per vertex (parks); flat otherwise (water)."""
bm = bmesh.new()
verts = [bm.verts.new((x, y, z + (H(x, y) if drape else 0.0))) 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 _subdivide(pts, step=8.0):
"""Split long segments so draped ribbons follow the terrain instead of
chording across it -- an OSM segment can run 100+ m, and a straight strip
over curved ground diverges from the collision mesh by metres. That read
in-game as the car sinking to its windows mid-block."""
out = [pts[0]]
for i in range(1, len(pts)):
(x1, y1), (x2, y2) = out[-1], pts[i]
L = math.hypot(x2 - x1, y2 - y1)
n = max(int(L / step), 1)
for k in range(1, n + 1):
out.append((x1 + (x2 - x1) * k / n, y1 + (y2 - y1) * k / n))
return out
def ribbon(bm, pts, half, z, mat=0, drop=None, offset=0.0, zs=None):
"""Strip of width 2*half along a polyline, shifted sideways by `offset`.
`drop` chamfers the edges down to z-PATH_H. Heights come from the terrain
(per segment END, not per corner, so roads don't twist across their width);
`zs` overrides with explicit per-point heights (bridge decks)."""
if zs is None and DEM is not None:
pts = _subdivide(pts)
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
h1 = zs[i] if zs else H(x1, y1)
h2 = zs[i + 1] if zs else H(x2, y2)
if zs is None:
CARVE.append((x1, y1, h1))
CARVE.append((x2, y2, h2))
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 + h1), (x1 - nx * half, y1 - ny * half, z + h1),
(x2 - nx * half, y2 - ny * half, z + h2), (x2 + nx * half, y2 + ny * half, z + h2)], mat)
else:
inner = max(half - PATH_CHAMFER, half * 0.35)
for s0, s1, d0, d1 in ((half, inner, drop, 0.0), (inner, -inner, 0.0, 0.0),
(-inner, -half, 0.0, drop)):
quad(bm, [(x1 + nx * s0, y1 + ny * s0, z - d0 + h1), (x1 + nx * s1, y1 + ny * s1, z - d1 + h1),
(x2 + nx * s1, y2 + ny * s1, z - d1 + h2), (x2 + nx * s0, y2 + ny * s0, z - d0 + h2)], mat)
# ------------------------------------------------------------------- props
def add_tree(fol, jac, frond, trunk, x, y, rng):
"""Fig (broad layered canopy), jacaranda (same shape, purple), or palm."""
z0 = H(x, y)
if rng.random() < 0.18:
h = rng.uniform(6.5, 9.5)
cone(trunk, (x, y, z0 + 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, z0 + h - rng.uniform(0.8, 1.6)))
beam(frond, Vector((x, y, z0 + 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, z0 + (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, z0 + 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):
z0 = H(x, y)
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, z0)) + d * s
beam(bm, c + Vector((0, 0, 0.02)), c + Vector((0, 0, 0.45)), 0.36, 0.07)
a0 = Vector((x, y, z0 + 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."""
z0 = H(x, y)
h = 3.9
cone(bm, (x, y, z0 + 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, z0)) + p * 0.22, z0 + h - 0.15),
(Vector((x, y, z0)) + d * 3.6, z0 + 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, z0 + h + 0.4)), Vector((x, y, z0 + 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."""
z0 = H(x, y)
h = 8.6
cone(bm, (x, y, z0 + h / 2), 0.19, 0.13, h, 6)
beam(bm, (x - 0.95, y, z0 + h - 0.5), (x + 0.95, y, z0 + h - 0.5), 0.11, 0.11)
tops = [Vector((x - 0.8, y, z0 + h - 0.38)), Vector((x, y, z0 + h - 0.05)), Vector((x + 0.8, y, z0 + 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, H(x, y)))
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)
# rigid terrain lift: base at the LOWEST terrain under the footprint so
# the uphill side cuts into the slope; walls run 2 m below base so no
# daylight shows under the downhill edge
bz = min(H(x, y) for x, y in ring)
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, bz - 2.0), (x2, y2, bz - 2.0), (x2, y2, bz + gf), (x1, y1, bz + gf)], 2)
quad(bm, [(x1, y1, bz + gf), (x2, y2, bz + gf), (x2, y2, bz + h), (x1, y1, bz + h)], 0)
try:
bm.faces.new([bm.verts.new((x, y, bz + 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, bz + h + 0.45), (x2, y2, bz + 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, bz + h + 1.4), (cx + 2.5, cy, bz + 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, bz + h + 2.6))
b = Vector((mx + ux * half, my + uy * half, bz + h + 2.6))
bb = bill_bm[e["id"] % 3]
beam(bb, a, b, 0.25, 3.0)
beam(bb, Vector((mx, my, bz + h)), Vector((mx, my, bz + 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 = bz + 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, WATER_Z)
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()
pz = min(H(x, y) for x, y in ring)
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, pz), (x2, y2, pz), (x2, y2, pz + 0.5), (x1, y1, pz + 0.5)], 0)
try:
bm.faces.new([bm.verts.new((x, y, pz + 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, drape=True)
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: PAVED (draped asphalt -- a summit lookout carpark is
# a burnout pad, not a dirt patch) with rows of parked-shitbox markers
# along the two longest edges, inset off the aisle -- Crash Mode fodder
try:
cp_bm = poly_bm(pts, 0.03, drape=True)
add_obj("carpark_%d" % e["id"], cp_bm, [M_CARPARK], 8.0)
except Exception:
pass
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("man_made") in ("mast", "tower", "communications_tower"):
# summit telecom mast: tapered lattice legs, cross-braces, a cage on
# top. Built into the power-pole bucket (steel) -- silhouette is what
# sells it against the sky, not detail.
mcx = sum(p[0] for p in pts) / len(pts)
mcy = sum(p[1] for p in pts) / len(pts)
mz = H(mcx, mcy)
MH = 34.0
for sx2, sy2 in ((-1, -1), (-1, 1), (1, -1), (1, 1)):
for seg in range(6):
f0, f1 = seg / 6.0, (seg + 1) / 6.0
r0 = 3.0 * (1.0 - f0 * 0.75)
r1 = 3.0 * (1.0 - f1 * 0.75)
beam(pole_bm,
Vector((mcx + sx2 * r0, mcy + sy2 * r0, mz + MH * f0)),
Vector((mcx + sx2 * r1, mcy + sy2 * r1, mz + MH * f1)), 0.28, 0.28)
for seg in range(1, 6):
f = seg / 6.0
r = 3.0 * (1.0 - f * 0.75)
for (ax2, ay2), (bx3, by3) in (((-1, -1), (-1, 1)), ((-1, 1), (1, 1)),
((1, 1), (1, -1)), ((1, -1), (-1, -1))):
beam(pole_bm,
Vector((mcx + ax2 * r, mcy + ay2 * r, mz + MH * f)),
Vector((mcx + bx3 * r, mcy + by3 * r, mz + MH * f)), 0.16, 0.16)
# the head: an open lattice cage of beams, like the real thing
for zc in (MH, MH + 6.0):
for (ax2, ay2), (bx3, by3) in (((-1, -1), (-1, 1)), ((-1, 1), (1, 1)),
((1, 1), (1, -1)), ((1, -1), (-1, -1))):
beam(pole_bm,
Vector((mcx + ax2 * 2.6, mcy + ay2 * 2.6, mz + zc)),
Vector((mcx + bx3 * 2.6, mcy + by3 * 2.6, mz + zc)), 0.2, 0.2)
for sx2, sy2 in ((-1, -1), (-1, 1), (1, -1), (1, 1)):
beam(pole_bm,
Vector((mcx + sx2 * 2.6, mcy + sy2 * 2.6, mz + MH)),
Vector((mcx + sx2 * 2.6, mcy + sy2 * 2.6, mz + MH + 6.0)), 0.2, 0.2)
beam(pole_bm, Vector((mcx, mcy, mz + MH + 6.0)),
Vector((mcx, mcy, mz + MH + 11.0)), 0.12, 0.12) # the whip antenna
counts["prop"] += 1
elif tags.get("natural") == "cliff":
bm = bmesh.new()
for i in range(len(pts) - 1):
beam(bm, Vector((pts[i][0], pts[i][1], H(*pts[i]) + 1.0)),
Vector((pts[i + 1][0], pts[i + 1][1], H(*pts[i + 1]) + 1.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")
deck = None
if BRIDGE_STYLE == "box" and bridge_name and tags.get("name") == bridge_name:
# every named way, bridge-tagged or not: the field is continuous,
# so approach carriageways blend into the climb with no seams
deck = [named_deck_z(px4, py4) for (px4, py4) in pts]
# guardrails: a 64 m deck with open edges is a cliff with lane
# markings. Two low collision walls chase the deck edges.
rail = bmesh.new()
for i5 in range(len(pts) - 1):
(rx1, ry1), (rx2, ry2) = pts[i5], pts[i5 + 1]
rl = math.hypot(rx2 - rx1, ry2 - ry1)
if rl < 0.5:
continue
rnx, rny = -(ry2 - ry1) / rl, (rx2 - rx1) / rl
for rs in (-1.0, 1.0):
ox = rnx * rs * (wide / 2 - 0.2)
oy = rny * rs * (wide / 2 - 0.2)
beam(rail,
Vector((rx1 + ox, ry1 + oy, deck[i5] + 0.5)),
Vector((rx2 + ox, ry2 + oy, deck[i5 + 1] + 0.5)), 0.25, 1.0)
add_obj("bridge_rail_%d-col" % e["id"], rail, [M_STEEL], 4.0)
elif tags.get("bridge") in ("yes", "viaduct") or (bridge_name and tags.get("name") == bridge_name):
deck = bridge_profile(pts, named=(tags.get("name") == bridge_name))
ribbon(road_bm[rkey], pts, wide / 2, 0.03, zs=deck)
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
dz = None
if deck is not None:
dz = [deck[i] + (deck[i + 1] - deck[i]) * t0,
deck[i] + (deck[i + 1] - deck[i]) * t1]
ribbon(line_bm, [(x1 + (x2 - x1) * t0, y1 + (y2 - y1) * t0),
(x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1)], 0.09, 0.045, zs=dz)
# 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))
if FOREST_TREES:
# forest fill: random sites across the area, rejected within ~12 m of any
# road (coarse occupancy grid -- naive point-to-segment over every road
# polyline would be 2600 x thousands). The 7 m dedup below still applies.
CELL = 12.0
road_cells = set()
rx0 = ry0 = float("inf")
rx1 = ry1 = float("-inf")
for pts, _w, _hw in road_ways:
for i in range(len(pts) - 1):
(ax, ay), (bx2, by2) = pts[i], pts[i + 1]
L = math.hypot(bx2 - ax, by2 - ay)
for k in range(int(L / 6.0) + 1):
t = k * 6.0 / L if L else 0.0
cx2, cy2 = ax + (bx2 - ax) * t, ay + (by2 - ay) * t
road_cells.add((int(cx2 // CELL), int(cy2 // CELL)))
rx0, ry0 = min(rx0, cx2), min(ry0, cy2)
rx1, ry1 = max(rx1, cx2), max(ry1, cy2)
frng = random.Random(4074)
placed_f = 0
road_list = list(road_cells)
for _ in range(FOREST_TREES * 4):
if placed_f >= FOREST_TREES:
break
# 65% of the forest hugs the roads (a ring 2-13 cells out) -- that's
# the forest you SEE from the driver's seat; the rest fills the map
if frng.random() < 0.65 and road_list:
bi, bj = road_list[frng.randrange(len(road_list))]
fx = (bi + frng.uniform(-13, 13)) * CELL
fy = (bj + frng.uniform(-13, 13)) * CELL
else:
fx = frng.uniform(rx0 - 60, rx1 + 60)
fy = frng.uniform(ry0 - 60, ry1 + 60)
ci, cj = int(fx // CELL), int(fy // CELL)
if any((ci + di, cj + dj) in road_cells for di in (-1, 0, 1) for dj in (-1, 0, 1)):
continue
tree_sites.append((fx, fy, hash((round(fx, 1), round(fy, 1))) & 0x7fffffff))
placed_f += 1
print("forest fill: %d sites" % placed_f)
seen = set()
TREE_CAP = MAX_TREES + FOREST_TREES
for tx, ty, seed in tree_sites:
if counts["tree"] >= TREE_CAP:
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-col", road_bm["road"], [M_ROAD], 8.0)
add_obj("mall_paving-col", road_bm["mall"], [M_MALL], 4.0)
add_obj("busway-col", 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()
deck_zs = (bridge_profile([(pp.x, pp.y) for pp in pts], named=True)
if BRIDGE_STYLE == "truss" else [named_deck_z(pp.x, pp.y) for pp in pts])
def deck_at(sd):
for i2 in range(1, len(dists)):
if dists[i2] >= sd:
f2 = (sd - dists[i2 - 1]) / max(dists[i2] - dists[i2 - 1], 0.001)
return deck_zs[i2 - 1] + (deck_zs[i2] - deck_zs[i2 - 1]) * f2
return deck_zs[-1]
samples = []
for i in range(N + 1):
t = i / N
sd = s0 + (s1 - s0) * t
p = at(sd)
p = Vector((p.x, p.y, deck_at(sd))) # truss base rides the deck
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(sd + 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)
if BRIDGE_STYLE == "truss":
add_obj("bridge_steel-col", bm, [M_STEEL], 6.0)
print("bridge truss built along %r (%.0fm span)" % (bridge_name, s1 - s0))
else:
bm.free()
if BRIDGE_STYLE == "box":
# concrete box-girder look: tall piers under EVERY bridge-tagged way
# of this name (both carriageways), from the ground/water up to the
# deck. The pier forest under a 64 m deck is the whole silhouette.
pier_bm = bmesh.new()
n_piers = 0
for e2 in d["elements"]:
t2 = e2.get("tags", {})
if t2.get("name") != bridge_name or not t2.get("bridge") or not e2.get("geometry"):
continue
bpts = [xy(g["lat"], g["lon"]) for g in e2["geometry"]]
if len(bpts) < 2:
continue
zs = [named_deck_z(px5, py5) for (px5, py5) in bpts]
bd = [0.0]
for i3 in range(1, len(bpts)):
bd.append(bd[-1] + math.hypot(bpts[i3][0] - bpts[i3 - 1][0],
bpts[i3][1] - bpts[i3 - 1][1]))
s = 40.0
while s < bd[-1] - 40.0:
for i3 in range(1, len(bd)):
if bd[i3] >= s:
f3 = (s - bd[i3 - 1]) / max(bd[i3] - bd[i3 - 1], 0.001)
px3 = bpts[i3 - 1][0] + (bpts[i3][0] - bpts[i3 - 1][0]) * f3
py3 = bpts[i3 - 1][1] + (bpts[i3][1] - bpts[i3 - 1][1]) * f3
dz = zs[i3 - 1] + (zs[i3] - zs[i3 - 1]) * f3
gz = min(H(px3, py3), 0.5) if dz > 30.0 else H(px3, py3)
if dz - gz > 9.0:
res3 = bmesh.ops.create_cube(pier_bm, size=1.0)
bmesh.ops.scale(pier_bm, vec=(2.6, 6.5, dz - gz),
verts=res3["verts"])
ang = math.atan2(bpts[i3][1] - bpts[i3 - 1][1],
bpts[i3][0] - bpts[i3 - 1][0])
bmesh.ops.rotate(pier_bm, cent=(0, 0, 0),
matrix=Matrix.Rotation(ang, 3, "Z"),
verts=res3["verts"])
bmesh.ops.translate(pier_bm,
vec=(px3, py3, gz + (dz - gz) / 2.0),
verts=res3["verts"])
n_piers += 1
break
s += 85.0
add_obj("bridge_piers-col", pier_bm, [M_CARPARK], 10.0)
print("bridge piers built along %r (%d piers)" % (bridge_name, n_piers))
# 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: a terrain grid sampled from the DEM (or a flat slab when there is
# none). Small triangles are deliberate -- one huge trimesh tri breaks 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]
x0g, x1g = min(xs) - 50, max(xs) + 50
y0g, y1g = min(ys) - 50, max(ys) + 50
if DEM is not None:
STEP = 9.0
nx = max(int((x1g - x0g) / STEP), 2)
ny = max(int((y1g - y0g) / STEP), 2)
sx = (x1g - x0g) / nx
sy = (y1g - y0g) / ny
carve = _np.full((ny + 1, nx + 1), _np.inf, dtype=_np.float64)
for cx, cy, ch in CARVE:
gi = int(round((cx - x0g) / sx))
gj = int(round((cy - y0g) / sy))
if 0 <= gi <= nx and 0 <= gj <= ny:
carve[gj, gi] = min(carve[gj, gi], ch)
# dilate one cell so the clamp covers the whole face a road crosses
dil = carve.copy()
for dj in (-1, 0, 1):
for di in (-1, 0, 1):
dil = _np.minimum(dil, _np.roll(_np.roll(carve, dj, axis=0), di, axis=1))
bm = bmesh.new()
grid = []
for j in range(ny + 1):
row = []
for i in range(nx + 1):
gx2 = x0g + i * sx
gy2 = y0g + j * sy
gz = H(gx2, gy2) - 0.04
if dil[j, i] != _np.inf:
gz = min(gz, dil[j, i] - 0.22)
row.append(bm.verts.new((gx2, gy2, gz)))
grid.append(row)
for j in range(ny):
for i in range(nx):
bm.faces.new((grid[j][i], grid[j][i + 1], grid[j + 1][i + 1], grid[j + 1][i]))
bmesh.ops.triangulate(bm, faces=bm.faces)
# grass at 18 m/tile reads as macro-photo lawn blades under the car;
# concrete never showed it because concrete has no scale cues
add_obj("terrain-col", bm, [M_GROUND], 3.0 if TERRAIN_TEX == "grass" else 18.0)
print("terrain grid %dx%d (%d carve samples)" % (nx, ny, len(CARVE)))
else:
gx, gy = (x0g + x1g) / 2, (y0g + y1g) / 2
bm = bmesh.new()
res = bmesh.ops.create_cube(bm, size=1.0)
bmesh.ops.scale(bm, vec=(x1g - x0g, y1g - y0g, 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, H(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)
dz2 = named_deck_z(p[0], p[1]) if BRIDGE_STYLE == "box" and bridge_name else H(p[0], p[1])
emp.location = (p[0], p[1], dz2 + 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)
spz = named_deck_z(sx, sy) if BRIDGE_STYLE == "box" and bridge_name else H(sx, sy)
sp.location = (sx, sy, spz + 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)
# box-bridge levels: the race line rides the DECK field, not the
# terrain -- at terrain height the grid spawns in the riverbed with
# the deck 57 m overhead and the AI races the ferry route
rz = named_deck_z(px, py) if BRIDGE_STYLE == "box" and bridge_name else H(px, py)
emp.location = (px, py, rz + 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)