ShitboxInfinity/tools/build_level_osm.py
m3ultra 82dae637a1 Scenery overhaul: real OSM street furniture, FLUX texture set v2, lighting pass
Builder now uses the furniture OSM already carries (footways, lamps, benches,
bins, bollards, bus stops, crossings): raised chamfered footpaths, shopfront
bands, awnings in three fabrics, parapets, layered fig/palm trees, zebra bars,
dashed centre lines, Queen St Mall winged canopies. ACES tonemap + SSAO + fog
in game.gd. flux_texture.sh gains size/luma clamping (FLUX renders "pale" as
blown-out white); gen_textures.sh + check_textures.py manage the set. Woolies
carpark rebuilt textured, parking the real fleet. Fixes: fractional OSM layer
tag crash, zero-length-normal UV projection, blood-red mall pavers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:13:29 +10:00

673 lines
29 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_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):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
_finish(mat, rough, metal).inputs["Base Color"].default_value = rgba
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)
FACADES = (M_BRICK, M_CONC, M_HERIT, M_SAND)
# ------------------------------------------------------------ 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, frond, trunk, x, y, rng):
"""Fig (broad layered canopy) or palm, seeded per site."""
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)
for i in range(3):
off = Vector((rng.uniform(-1, 1), rng.uniform(-1, 1), 0)) * r * 0.42
blob(fol, (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_lamp(bm, x, y, rng):
h = 7.5
cone(bm, (x, y, h / 2), 0.13, 0.09, h, 6)
arm = Vector((math.cos(rng.random() * math.tau), math.sin(rng.random() * math.tau), 0)) * 1.5
beam(bm, Vector((x, y, h - 0.2)), Vector((x, y, h - 0.2)) + arm, 0.11, 0.11)
slab(bm, [(x + arm.x - 0.4, y + arm.y - 0.25, h - 0.35), (x + arm.x + 0.4, y + arm.y - 0.25, h - 0.35),
(x + arm.x + 0.4, y + arm.y + 0.25, h - 0.35), (x + arm.x - 0.4, y + arm.y + 0.25, h - 0.35)], 0.12)
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_bin(bm, x, y, rng):
cone(bm, (x, y, 0.45), 0.30, 0.27, 0.9, 8)
def add_bollard(bm, x, y, rng):
cone(bm, (x, y, 0.45), 0.10, 0.09, 0.9, 6)
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"), 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}
PROPS = {"tree": None, "street_lamp": add_lamp, "bench": add_bench,
"waste_basket": add_bin, "bollard": add_bollard, "bus_stop": None}
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()} # batched: 385 road objects was 385 draw calls
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 in PROPS and PROPS[kind]:
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"]
if bt in ("tower", "office", "hotel", "apartments") and h > 30 or h > 55:
fac = M_GLASS
elif h < 16 and bt in ("retail", "commercial", "yes"):
fac = (M_HERIT, M_BRICK, M_SAND)[e["id"] % 3]
else:
fac = FACADES[e["id"] % 4]
bm = bmesh.new()
gf = min(GROUND_FLOOR, h * 0.45)
for i in range(len(ring)):
(x1, y1), (x2, y2) = ring[i], ring[(i + 1) % len(ring)]
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
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_ROOF, M_SHOP],
{0: 13.0, 1: 9.0, 2: 4.6})
counts["bld"] += 1
# 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 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)
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("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)
ribbon(road_bm["mall" if hw == "pedestrian" else "road"], pts, wide / 2, 0.03)
counts["road"] += 1
road_ways.append((pts, wide, hw))
# 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, 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("trees_foliage", fol_bm, [M_FOLIAGE], 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("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()
# 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)
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)