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>
1286 lines
60 KiB
Python
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)
|