Every OSM level now spawns 50 shuntable parked fleet cars + 40 wheelie bins from builder-emitted PP_/WB_ markers (kerbside + real amenity=parking rows; asleep until rammed, absent in junctions to keep the puzzles deterministic). Traffic bumped in cruise/crash. Brisbane pass: red busway asphalt, jacaranda canopies on a fifth of the street trees, Queenslander weatherboard + corro on building=house, art deco + glass apartment facade variants, fictional shop-fascia signs above awnings, rooftop billboards. Southbank gets the Wheel of Brisbane (by its mapped ticket office) and the Grand Arbour's bougainvillea tendrils along the mapped footway. Verified: smoke green, 50+40 smashables counted on southbank, screenshots of the wheel, arbour trail, red busway, and one authentic parked-car rear-ending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
839 lines
38 KiB
Python
839 lines
38 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)
|
|
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)
|
|
M_BILL = tex_material("billboard_ad", "billboard_ad.jpg", 0.7)
|
|
|
|
FACADES = (M_BRICK, M_CONC, M_HERIT, M_SAND, M_DECO)
|
|
QLD_TAGS = ("house", "detached", "terrace", "bungalow", "semidetached_house", "residential")
|
|
|
|
# ------------------------------------------------------------ mesh helpers
|
|
|
|
def uv_project(bm, scale):
|
|
"""Box-project UVs. `scale` is metres-per-tile: a float, or {mat_index: float}."""
|
|
bm.normal_update() # fresh faces have zero normals -> wrong projection axis
|
|
uv = bm.loops.layers.uv.new("UVMap")
|
|
for f in bm.faces:
|
|
s = scale[f.material_index] if isinstance(scale, dict) else scale
|
|
n = f.normal
|
|
ax = max(range(3), key=lambda i: abs(n[i]))
|
|
for l in f.loops:
|
|
c = l.vert.co
|
|
if ax == 2:
|
|
l[uv].uv = (c.x / s, c.y / s)
|
|
elif ax == 0:
|
|
l[uv].uv = (c.y / s, c.z / s)
|
|
else:
|
|
l[uv].uv = (c.x / s, c.z / s)
|
|
|
|
def add_obj(name, bm, mats, uv_scale, smooth=False):
|
|
if not bm.faces:
|
|
bm.free()
|
|
return None
|
|
if smooth:
|
|
# every primitive is built with its own verts, so a batched canopy mesh
|
|
# exports ~6x the vertices it needs; welding them also rounds the shading
|
|
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=0.002)
|
|
for f in bm.faces:
|
|
f.smooth = True
|
|
uv_project(bm, uv_scale)
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
for m in mats:
|
|
mesh.materials.append(m)
|
|
obj = bpy.data.objects.new(name, mesh)
|
|
bpy.context.collection.objects.link(obj)
|
|
return obj
|
|
|
|
def quad(bm, pts, mat=0):
|
|
"""Face from 4 points (tuples or Vectors), wound as given."""
|
|
try:
|
|
f = bm.faces.new([bm.verts.new(Vector(p)) for p in pts])
|
|
f.material_index = mat
|
|
return f
|
|
except ValueError:
|
|
return None
|
|
|
|
def slab(bm, top, thick, mat=0, side_mat=None):
|
|
"""Thin box from 4 top-face corners, extruded downward by `thick`."""
|
|
side_mat = mat if side_mat is None else side_mat
|
|
t = [Vector(p) for p in top]
|
|
b = [p - Vector((0, 0, thick)) for p in t]
|
|
quad(bm, t, mat)
|
|
quad(bm, list(reversed(b)), mat)
|
|
for i in range(4):
|
|
j = (i + 1) % 4
|
|
quad(bm, [b[i], b[j], t[j], t[i]], side_mat)
|
|
|
|
def _paint(verts, mat):
|
|
"""Material-index the faces a primitive op just made. Blender 5's create_*
|
|
ops return only 'verts', and the new verts link to exactly the new faces."""
|
|
if mat:
|
|
for f in {f for v in verts for f in v.link_faces}:
|
|
f.material_index = mat
|
|
|
|
def beam(bm, a, b, w, h, mat=0):
|
|
"""Oriented box from point a to point b (Vectors)."""
|
|
axis = Vector(b) - Vector(a)
|
|
L = axis.length
|
|
if L < 0.05:
|
|
return
|
|
quat = axis.to_track_quat("X", "Z")
|
|
m = Matrix.Translation((Vector(a) + Vector(b)) / 2) @ quat.to_matrix().to_4x4()
|
|
verts = bmesh.ops.create_cube(bm, size=1.0)["verts"]
|
|
bmesh.ops.scale(bm, vec=(L, w, h), verts=verts)
|
|
bmesh.ops.transform(bm, matrix=m, verts=verts)
|
|
_paint(verts, mat)
|
|
|
|
def cone(bm, at, r1, r2, depth, segments=7, mat=0):
|
|
verts = bmesh.ops.create_cone(bm, cap_ends=True, segments=segments,
|
|
radius1=r1, radius2=r2, depth=depth)["verts"]
|
|
bmesh.ops.translate(bm, vec=at, verts=verts)
|
|
_paint(verts, mat)
|
|
return verts
|
|
|
|
def blob(bm, at, radii, subdiv=1, mat=0):
|
|
"""Squashed icosphere -- the tree canopy primitive."""
|
|
try:
|
|
verts = bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0)["verts"]
|
|
except TypeError: # Blender < 4.0
|
|
verts = bmesh.ops.create_icosphere(bm, subdivisions=subdiv, diameter=2.0)["verts"]
|
|
bmesh.ops.scale(bm, vec=radii, verts=verts)
|
|
bmesh.ops.translate(bm, vec=at, verts=verts)
|
|
_paint(verts, mat)
|
|
|
|
def ring_ccw(pts):
|
|
"""Open vertex ring wound counter-clockwise (so edge normals point outward)."""
|
|
r = pts[:-1] if len(pts) > 2 and pts[0] == pts[-1] else list(pts)
|
|
area = sum(r[i][0] * r[(i + 1) % len(r)][1] - r[(i + 1) % len(r)][0] * r[i][1]
|
|
for i in range(len(r)))
|
|
return r if area > 0 else list(reversed(r))
|
|
|
|
def poly_bm(pts, z, h=0.0):
|
|
"""Filled (optionally extruded) polygon bmesh from closed pt list."""
|
|
bm = bmesh.new()
|
|
verts = [bm.verts.new((x, y, z)) for x, y in pts[:-1]]
|
|
face = bm.faces.new(verts)
|
|
if h > 0.0:
|
|
res = bmesh.ops.extrude_face_region(bm, geom=[face])
|
|
up = [v for v in res["geom"] if isinstance(v, bmesh.types.BMVert)]
|
|
bmesh.ops.translate(bm, vec=(0, 0, h), verts=up)
|
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
|
bmesh.ops.triangulate(bm, faces=bm.faces)
|
|
return bm
|
|
|
|
def ribbon(bm, pts, half, z, mat=0, drop=None, offset=0.0):
|
|
"""Strip of width 2*half along a polyline, shifted sideways by `offset`.
|
|
`drop` chamfers the edges down to z-PATH_H."""
|
|
for i in range(len(pts) - 1):
|
|
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
|
|
dx, dy = x2 - x1, y2 - y1
|
|
L = math.hypot(dx, dy)
|
|
if L < 0.1:
|
|
continue
|
|
nx, ny = -dy / L, dx / L
|
|
if offset:
|
|
x1, y1 = x1 + nx * offset, y1 + ny * offset
|
|
x2, y2 = x2 + nx * offset, y2 + ny * offset
|
|
if drop is None:
|
|
quad(bm, [(x1 + nx * half, y1 + ny * half, z), (x1 - nx * half, y1 - ny * half, z),
|
|
(x2 - nx * half, y2 - ny * half, z), (x2 + nx * half, y2 + ny * half, z)], mat)
|
|
else:
|
|
inner = max(half - PATH_CHAMFER, half * 0.35)
|
|
for s0, s1, z0, z1 in ((half, inner, z - drop, z), (inner, -inner, z, z),
|
|
(-inner, -half, z, z - drop)):
|
|
quad(bm, [(x1 + nx * s0, y1 + ny * s0, z0), (x1 + nx * s1, y1 + ny * s1, z1),
|
|
(x2 + nx * s1, y2 + ny * s1, z1), (x2 + nx * s0, y2 + ny * s0, z0)], mat)
|
|
|
|
# ------------------------------------------------------------------- props
|
|
|
|
def add_tree(fol, jac, frond, trunk, x, y, rng):
|
|
"""Fig (broad layered canopy), jacaranda (same shape, purple), or palm."""
|
|
if rng.random() < 0.18:
|
|
h = rng.uniform(6.5, 9.5)
|
|
cone(trunk, (x, y, h / 2), 0.22, 0.14, h, 7)
|
|
for i in range(7):
|
|
a = i * math.tau / 7 + rng.uniform(-0.25, 0.25)
|
|
reach = rng.uniform(1.8, 2.5)
|
|
tip = Vector((x + math.cos(a) * reach, y + math.sin(a) * reach, h - rng.uniform(0.8, 1.6)))
|
|
beam(frond, Vector((x, y, h)), tip, 0.7, 0.12)
|
|
else:
|
|
# canopy well clear of the roof line of a car, and built from three
|
|
# offset spheres so the silhouette breaks up instead of reading as a ball
|
|
clear = rng.uniform(3.6, 4.8)
|
|
r = rng.uniform(1.7, 2.5)
|
|
cone(trunk, (x, y, (clear + 1.0) / 2), 0.20, 0.13, clear + 1.0, 7)
|
|
target = jac if rng.random() < 0.22 else fol # jacaranda season is eternal here
|
|
for i in range(3):
|
|
off = Vector((rng.uniform(-1, 1), rng.uniform(-1, 1), 0)) * r * 0.42
|
|
blob(target, (x + off.x, y + off.y, clear + r * (0.45 + 0.34 * i)),
|
|
(r * rng.uniform(0.75, 1.05), r * rng.uniform(0.75, 1.05), r * rng.uniform(0.55, 0.75)),
|
|
subdiv=2)
|
|
|
|
def add_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(), "bus": bmesh.new()} # batched: 385 road objects was 385 draw calls
|
|
sign_bm, bill_bm, jac_bm = bmesh.new(), bmesh.new(), bmesh.new()
|
|
pp_spots, wb_spots = [], [] # (x, y, yaw): parked-car / wheelie-bin markers for game.gd to fill
|
|
PP_CAP, WB_CAP = 70, 40
|
|
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 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"]
|
|
qld = bt in QLD_TAGS
|
|
if qld:
|
|
# Queenslander: low weatherboard box under corrugated iron, no
|
|
# shopfront band, no parapet -- houses have eaves, not parapets
|
|
h = 4.2 + (e["id"] % 3) * 0.6
|
|
fac = M_WEATHER
|
|
elif bt in ("tower", "office", "hotel", "apartments") and h > 30 or h > 55:
|
|
fac = M_GLASS if e["id"] % 3 else M_GLASS2
|
|
elif h < 16 and bt in ("retail", "commercial", "yes"):
|
|
fac = (M_HERIT, M_BRICK, M_SAND)[e["id"] % 3]
|
|
else:
|
|
fac = FACADES[e["id"] % 5]
|
|
|
|
bm = bmesh.new()
|
|
gf = 0.0 if qld else min(GROUND_FLOOR, h * 0.45)
|
|
for i in range(len(ring)):
|
|
(x1, y1), (x2, y2) = ring[i], ring[(i + 1) % len(ring)]
|
|
if gf > 0.0:
|
|
quad(bm, [(x1, y1, 0), (x2, y2, 0), (x2, y2, gf), (x1, y1, gf)], 2)
|
|
quad(bm, [(x1, y1, gf), (x2, y2, gf), (x2, y2, h), (x1, y1, h)], 0)
|
|
try:
|
|
bm.faces.new([bm.verts.new((x, y, h)) for x, y in ring]).material_index = 1
|
|
except ValueError:
|
|
pass
|
|
if not qld:
|
|
for i in range(len(ring)): # parapet caps the roof edge
|
|
(x1, y1), (x2, y2) = ring[i], ring[(i + 1) % len(ring)]
|
|
beam(bm, (x1, y1, h + 0.45), (x2, y2, h + 0.45), 0.35, 0.9, 0)
|
|
if h > 26: # rooftop plant room
|
|
cx = sum(p[0] for p in ring) / len(ring)
|
|
cy = sum(p[1] for p in ring) / len(ring)
|
|
beam(bm, (cx - 2.5, cy, h + 1.4), (cx + 2.5, cy, h + 1.4), 4.0, 2.8, 1)
|
|
bmesh.ops.triangulate(bm, faces=bm.faces)
|
|
add_obj("bld_%d-col" % e["id"], bm, [fac, M_CORRO if qld else M_ROOF, M_SHOP],
|
|
{0: 13.0 if not qld else 6.5, 1: 9.0 if not qld else 3.0, 2: 4.6})
|
|
counts["bld"] += 1
|
|
|
|
# rooftop billboard on a slice of the mid-rises, along the longest edge
|
|
if not qld and 12.0 < h < 40.0 and rng.random() < 0.16:
|
|
bi = max(range(len(ring)),
|
|
key=lambda i: math.dist(ring[i], ring[(i + 1) % len(ring)]))
|
|
(ex1, ey1), (ex2, ey2) = ring[bi], ring[(bi + 1) % len(ring)]
|
|
eL = math.dist((ex1, ey1), (ex2, ey2))
|
|
if eL > 8.0:
|
|
mx, my = (ex1 + ex2) / 2, (ey1 + ey2) / 2
|
|
ux, uy = (ex2 - ex1) / eL, (ey2 - ey1) / eL
|
|
half = min(4.0, eL * 0.35)
|
|
a = Vector((mx - ux * half, my - uy * half, h + 2.6))
|
|
b = Vector((mx + ux * half, my + uy * half, h + 2.6))
|
|
beam(bill_bm, a, b, 0.25, 3.0)
|
|
beam(bill_bm, Vector((mx, my, h)), Vector((mx, my, h + 1.4)), 0.3, 0.3)
|
|
|
|
# street awning over the footpath -- not every shop has one, and a whole
|
|
# street at one height in one colour reads as a painted stripe
|
|
if not qld and 6.0 < h < 45.0 and rng.random() < 0.7:
|
|
az = AWNING_Z + rng.uniform(-0.35, 0.5)
|
|
amat = e["id"] % 3
|
|
out = AWNING_OUT * rng.uniform(0.85, 1.1)
|
|
for i in range(len(ring)):
|
|
(x1, y1), (x2, y2) = ring[i], ring[(i + 1) % len(ring)]
|
|
dx, dy = x2 - x1, y2 - y1
|
|
L = math.hypot(dx, dy)
|
|
if L < 4.0:
|
|
continue
|
|
ox, oy = dy / L * out, -dx / L * out
|
|
slab(awn_bm, [(x1, y1, az + 0.35), (x2, y2, az + 0.35),
|
|
(x2 + ox, y2 + oy, az), (x1 + ox, y1 + oy, az)], 0.14, amat)
|
|
# shop fascia signs above the awning, proud of the facade
|
|
sx, sy = dy / L * 0.07, -dx / L * 0.07
|
|
quad(sign_bm, [(x1 + sx, y1 + sy, az + 0.42), (x2 + sx, y2 + sy, az + 0.42),
|
|
(x2 + sx, y2 + sy, az + 1.15), (x1 + sx, y1 + sy, az + 1.15)])
|
|
|
|
elif tags.get("natural") == "water" or tags.get("water"):
|
|
if not closed:
|
|
continue
|
|
try:
|
|
bm = poly_bm(pts, -0.35)
|
|
except Exception:
|
|
continue
|
|
# ponytail: water is solid (-col) so cars skim it instead of falling forever
|
|
add_obj("water_%d-col" % e["id"], bm, [M_WATER], 30.0)
|
|
counts["water"] += 1
|
|
|
|
elif tags.get("leisure") in PLANTER_LEISURE:
|
|
if not closed or len(pts) < 4:
|
|
continue
|
|
ring = ring_ccw(pts)
|
|
bm = bmesh.new()
|
|
for i in range(len(ring)): # raised corten planter box
|
|
(x1, y1), (x2, y2) = ring[i], ring[(i + 1) % len(ring)]
|
|
quad(bm, [(x1, y1, 0), (x2, y2, 0), (x2, y2, 0.5), (x1, y1, 0.5)], 0)
|
|
try:
|
|
bm.faces.new([bm.verts.new((x, y, 0.5)) for x, y in ring]).material_index = 1
|
|
except ValueError:
|
|
bm.free()
|
|
continue
|
|
bmesh.ops.triangulate(bm, faces=bm.faces)
|
|
add_obj("planter_%d" % e["id"], bm, [M_PLANTER, M_FOLIAGE], {0: 2.5, 1: 3.5})
|
|
counts["planter"] += 1
|
|
|
|
elif tags.get("leisure") in GREEN_LEISURE or tags.get("landuse") in GREEN_LANDUSE \
|
|
or tags.get("natural") in ("grassland", "scrub", "wood"):
|
|
if not closed:
|
|
continue
|
|
try:
|
|
bm = poly_bm(pts, 0.02)
|
|
except Exception:
|
|
continue
|
|
add_obj("park_%d" % e["id"], bm, [M_GRASS], 14.0)
|
|
counts["park"] += 1
|
|
|
|
elif tags.get("amenity") == "parking" and closed:
|
|
# surface carparks: rows of parked-shitbox markers along the two longest
|
|
# edges, inset off the aisle -- prime Crash Mode fodder
|
|
ring = ring_ccw(pts)
|
|
edges = sorted(range(len(ring)),
|
|
key=lambda i: -math.dist(ring[i], ring[(i + 1) % len(ring)]))
|
|
for bi in edges[:2]:
|
|
(ex1, ey1), (ex2, ey2) = ring[bi], ring[(bi + 1) % len(ring)]
|
|
eL = math.dist((ex1, ey1), (ex2, ey2))
|
|
if eL < 10.0:
|
|
continue
|
|
ux, uy = (ex2 - ex1) / eL, (ey2 - ey1) / eL
|
|
yaw = math.atan2(uy, ux) + math.pi / 2
|
|
k = 3.5
|
|
while k < eL - 3.5 and len(pp_spots) < PP_CAP * 3:
|
|
if rng.random() < 0.6:
|
|
pp_spots.append((ex1 + ux * k - uy * 3.0, ey1 + uy * k + ux * 3.0, yaw))
|
|
k += 6.0
|
|
|
|
elif tags.get("natural") == "cliff":
|
|
bm = bmesh.new()
|
|
for i in range(len(pts) - 1):
|
|
beam(bm, Vector((pts[i][0], pts[i][1], 7.0)),
|
|
Vector((pts[i + 1][0], pts[i + 1][1], 7.0)), 2.0, 14.0)
|
|
add_obj("cliff_%d-col" % e["id"], bm, [M_ROCK], 12.0)
|
|
counts["cliff"] += 1
|
|
|
|
elif tags.get("highway"):
|
|
hw = tags["highway"]
|
|
if hw in SKIP_HW:
|
|
continue
|
|
try: # OSM layer is sometimes "0.5", or junk
|
|
layer = float(tags.get("layer") or 0)
|
|
except ValueError:
|
|
layer = 0.0
|
|
if tags.get("tunnel") == "yes" or layer < 0:
|
|
continue
|
|
|
|
if hw in ("footway", "path", "cycleway"):
|
|
if tags.get("footway") == "crossing" or tags.get("crossing"):
|
|
# actual zebra bars -- one solid strip reads as a white slab
|
|
for s in (-1.5, -0.75, 0.0, 0.75, 1.5):
|
|
ribbon(line_bm, pts, 0.22, 0.045, offset=s)
|
|
counts["cross"] += 1
|
|
else:
|
|
ribbon(path_bm, pts, PATH_W.get(hw, 2.4) / 2, PATH_H, drop=PATH_H)
|
|
counts["path"] += 1
|
|
continue
|
|
|
|
wide = ROAD_W.get(hw, 6.0)
|
|
rkey = "mall" if hw == "pedestrian" else ("bus" if hw == "busway" else "road")
|
|
ribbon(road_bm[rkey], pts, wide / 2, 0.03)
|
|
counts["road"] += 1
|
|
if hw in 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))
|
|
# no footpath strip on a pedestrian mall -- the mall paving IS the surface
|
|
if hw != "pedestrian" and wide >= 7.0 and hw not in ("busway",): # dashed centre line
|
|
for i in range(len(pts) - 1):
|
|
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
|
|
L = math.hypot(x2 - x1, y2 - y1)
|
|
for k in range(int(L / 9.0)):
|
|
t0, t1 = (k * 9.0 + 1.5) / L, (k * 9.0 + 5.0) / L
|
|
ribbon(line_bm, [(x1 + (x2 - x1) * t0, y1 + (y2 - y1) * t0),
|
|
(x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1)], 0.09, 0.045)
|
|
|
|
# street trees: OSM maps a fraction of what Brisbane actually has, so line the
|
|
# kerbs of named streets and the mall edges with our own, deterministically
|
|
for pts, wide, hw in road_ways:
|
|
if hw in ("motorway", "trunk", "busway", "service"):
|
|
continue
|
|
step = 13.0 if hw == "pedestrian" else 24.0
|
|
off = wide / 2 - (2.6 if hw == "pedestrian" else -2.4) # mall trees line the edges
|
|
for i in range(len(pts) - 1):
|
|
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
|
|
dx, dy = x2 - x1, y2 - y1
|
|
L = math.hypot(dx, dy)
|
|
if L < step:
|
|
continue
|
|
nx, ny = -dy / L, dx / L
|
|
for k in range(1, int(L / step)):
|
|
t = k * step / L
|
|
bx, by = x1 + dx * t, y1 + dy * t
|
|
for s in (-1, 1):
|
|
tree_sites.append((bx + nx * off * s, by + ny * off * s,
|
|
hash((round(bx, 1), round(by, 1), s)) & 0x7fffffff))
|
|
|
|
seen = set()
|
|
for tx, ty, seed in tree_sites:
|
|
if counts["tree"] >= MAX_TREES:
|
|
break
|
|
key = (round(tx / 7.0), round(ty / 7.0)) # no two trees in one 7 m cell
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
add_tree(fol_bm, jac_bm, frond_bm, trunk_bm, tx, ty, random.Random(seed))
|
|
counts["tree"] += 1
|
|
|
|
add_obj("roads", road_bm["road"], [M_ROAD], 8.0)
|
|
add_obj("mall_paving", road_bm["mall"], [M_MALL], 4.0)
|
|
add_obj("busway", road_bm["bus"], [M_BUSRED], 8.0)
|
|
add_obj("trees_foliage", fol_bm, [M_FOLIAGE], 0.9, smooth=True)
|
|
add_obj("trees_jacaranda", jac_bm, [M_JAC], 0.9, smooth=True)
|
|
add_obj("trees_fronds", frond_bm, [M_FOLIAGE], 0.9)
|
|
add_obj("trees_trunks", trunk_bm, [M_BARK], 0.7, smooth=True)
|
|
add_obj("shop_signs", sign_bm, [M_SIGN], 5.5)
|
|
add_obj("billboards", bill_bm, [M_BILL], 8.0)
|
|
add_obj("props", prop_bm, [M_DARK], 1.6)
|
|
add_obj("prop_glass", glass_bm, [M_GLASS], 3.0)
|
|
add_obj("awnings", awn_bm, AWNINGS, 3.0)
|
|
add_obj("footpaths-col", path_bm, [M_PATH], 3.5)
|
|
add_obj("roadlines", line_bm, [M_LINE], 3.0)
|
|
|
|
# steel truss along a named bridge way
|
|
if bridge_name:
|
|
cands = [e for e in d["elements"] if e.get("tags", {}).get("name") == bridge_name and e.get("geometry")]
|
|
if cands:
|
|
way = max(cands, key=lambda e: len(e["geometry"]))
|
|
pts = [Vector((*xy(g["lat"], g["lon"]), 0.0)) for g in way["geometry"]]
|
|
# arc-length resample the middle 65% (the steel section)
|
|
dists = [0.0]
|
|
for i in range(1, len(pts)):
|
|
dists.append(dists[-1] + (pts[i] - pts[i - 1]).length)
|
|
T = dists[-1]
|
|
def at(s):
|
|
for i in range(1, len(dists)):
|
|
if dists[i] >= s:
|
|
f = (s - dists[i - 1]) / max(dists[i] - dists[i - 1], 0.001)
|
|
return pts[i - 1].lerp(pts[i], f)
|
|
return pts[-1]
|
|
s0, s1 = 0.175 * T, 0.825 * T
|
|
N = 26
|
|
bm = bmesh.new()
|
|
samples = []
|
|
for i in range(N + 1):
|
|
t = i / N
|
|
p = at(s0 + (s1 - s0) * t)
|
|
h = 4.0 + 16.0 * (math.exp(-((t - 0.32) / 0.10) ** 2) + math.exp(-((t - 0.68) / 0.10) ** 2)) \
|
|
+ 6.0 * math.sin(math.pi * t)
|
|
fwd = (at(min(s0 + (s1 - s0) * t + 4, T)) - p)
|
|
fwd.z = 0
|
|
fwd.normalize()
|
|
samples.append((p, Vector((-fwd.y, fwd.x, 0)), h))
|
|
for side in (-1, 1):
|
|
for i in range(N):
|
|
p1, pe1, h1 = samples[i]
|
|
p2, pe2, h2 = samples[i + 1]
|
|
a = p1 + pe1 * side * 8.0
|
|
b = p2 + pe2 * side * 8.0
|
|
beam(bm, a + Vector((0, 0, h1)), b + Vector((0, 0, h2)), 0.7, 0.7) # top chord
|
|
beam(bm, a + Vector((0, 0, 0.5)), b + Vector((0, 0, 0.5)), 0.5, 0.5) # bottom chord
|
|
beam(bm, a + Vector((0, 0, 0.5)), a + Vector((0, 0, h1)), 0.5, 0.5) # vertical
|
|
if i % 2 == 0:
|
|
beam(bm, a + Vector((0, 0, 0.5)), b + Vector((0, 0, h2)), 0.4, 0.4) # diagonal
|
|
for i in range(0, N + 1, 3):
|
|
p, pe, h = samples[i]
|
|
beam(bm, p - pe * 8.0 + Vector((0, 0, h)), p + pe * 8.0 + Vector((0, 0, h)), 0.5, 0.5)
|
|
add_obj("bridge_steel-col", bm, [M_STEEL], 6.0)
|
|
print("bridge truss built along %r (%.0fm span)" % (bridge_name, s1 - s0))
|
|
|
|
# Winged shade canopies along pedestrian ways whose name starts with the given
|
|
# one -- OSM splits Queen Street Mall into a 15 m stub named "Queen Street Mall"
|
|
# plus the 438 m pedestrian way that is just "Queen Street", so match the prefix.
|
|
if canopy_name:
|
|
ways = [e for e in d["elements"]
|
|
if e.get("tags", {}).get("highway") == "pedestrian" and e.get("geometry")
|
|
and e.get("tags", {}).get("name", "").startswith(canopy_name)]
|
|
bm = bmesh.new()
|
|
n = 0
|
|
for way in ways:
|
|
pts = [Vector((*xy(g["lat"], g["lon"]), 0.0)) for g in way["geometry"]]
|
|
acc = 22.0
|
|
for i in range(len(pts) - 1):
|
|
seg = pts[i + 1] - pts[i]
|
|
L = seg.length
|
|
if L < 0.5:
|
|
continue
|
|
fwd = seg.normalized()
|
|
while acc < L:
|
|
add_canopy(bm, pts[i] + fwd * acc, fwd)
|
|
acc += 48.0
|
|
n += 1
|
|
acc -= L
|
|
if n:
|
|
add_obj("mall_canopies-col", bm, [M_PANEL], 4.0)
|
|
print("canopies built along %r (%d)" % (canopy_name, n))
|
|
else:
|
|
bm.free()
|
|
|
|
# ---- hero landmarks, keyed by level id -------------------------------------
|
|
if level_id == "southbank":
|
|
# The Wheel of Brisbane: white steel, ~55 m, next to its mapped ticket office
|
|
tick = [e for e in d["elements"]
|
|
if "Wheel of Brisbane" in e.get("tags", {}).get("name", "") and e.get("geometry")]
|
|
if tick:
|
|
g = tick[0]["geometry"]
|
|
cx = sum(xy(p["lat"], p["lon"])[0] for p in g) / len(g) + 12.0
|
|
cy = sum(xy(p["lat"], p["lon"])[1] for p in g) / len(g) + 8.0
|
|
bm = bmesh.new()
|
|
R, HUB = 24.0, 30.0
|
|
N = 18
|
|
pts_w = [Vector((cx, cy + math.cos(i / N * math.tau) * R,
|
|
HUB + math.sin(i / N * math.tau) * R)) for i in range(N)]
|
|
hub = Vector((cx, cy, HUB))
|
|
for i in range(N):
|
|
beam(bm, pts_w[i], pts_w[(i + 1) % N], 0.45, 0.45) # rim
|
|
beam(bm, hub, pts_w[i], 0.28, 0.28) # spokes
|
|
if i % 2 == 0: # gondolas
|
|
p = pts_w[i]
|
|
beam(bm, p + Vector((-0.8, 0, -1.6)), p + Vector((0.8, 0, -1.6)), 1.3, 1.5)
|
|
for sy in (-7.0, 7.0): # A-frame legs
|
|
for sx in (-4.0, 4.0):
|
|
beam(bm, Vector((cx + sx, cy + sy, 0.0)), hub, 0.6, 0.6)
|
|
beam(bm, Vector((cx - 4.5, cy, 0.6)), Vector((cx + 4.5, cy, 0.6)), 10.0, 1.2) # base
|
|
add_obj("wheel_of_brisbane-col", bm, [M_PANEL], 5.0)
|
|
print("wheel of brisbane at (%.0f, %.0f)" % (cx, cy))
|
|
|
|
# Grand Arbour: curling steel tendrils drowning in bougainvillea
|
|
ways = [e for e in d["elements"]
|
|
if e.get("tags", {}).get("name") == "Grand Arbour" and len(e.get("geometry") or []) >= 8]
|
|
abm = bmesh.new()
|
|
n_t = 0
|
|
for way in ways:
|
|
wpts = [Vector((*xy(g["lat"], g["lon"]), 0.0)) for g in way["geometry"]]
|
|
acc = 3.0
|
|
for i in range(len(wpts) - 1):
|
|
seg = wpts[i + 1] - wpts[i]
|
|
segL = seg.length
|
|
if segL < 0.3:
|
|
continue
|
|
fwd = seg / segL
|
|
perp = Vector((-fwd.y, fwd.x, 0))
|
|
while acc < segL:
|
|
p = wpts[i] + fwd * acc
|
|
arc = [p - perp * 2.0, p - perp * 1.3 + Vector((0, 0, 3.1)),
|
|
p + perp * 0.4 + Vector((0, 0, 4.3)), p + perp * 1.8 + Vector((0, 0, 3.2))]
|
|
for a, b in zip(arc, arc[1:]):
|
|
beam(abm, a, b, 0.14, 0.14)
|
|
if n_t % 2 == 0:
|
|
blob(abm, (arc[2].x, arc[2].y, arc[2].z + 0.4), (1.4, 1.4, 0.9), mat=1)
|
|
n_t += 1
|
|
acc += 5.0
|
|
acc -= segL
|
|
if n_t:
|
|
# steel tendrils (mat 0) + bougainvillea blobs (mat 1); no collision --
|
|
# slim posts as trimesh would tunnel weirdly at speed
|
|
uv_project(abm, {0: 2.0, 1: 1.2})
|
|
mesh_a = bpy.data.meshes.new("arbour")
|
|
abm.to_mesh(mesh_a)
|
|
abm.free()
|
|
mesh_a.materials.append(M_STEEL)
|
|
mesh_a.materials.append(M_BOUG)
|
|
obj_a = bpy.data.objects.new("arbour", mesh_a)
|
|
bpy.context.collection.objects.link(obj_a)
|
|
print("grand arbour tendrils: %d" % n_t)
|
|
|
|
# ground slab sized to content (convex collider: huge trimesh tris break raycasts)
|
|
xs = [v.co.x for o in bpy.data.objects if o.type == "MESH" for v in o.data.vertices]
|
|
ys = [v.co.y for o in bpy.data.objects if o.type == "MESH" for v in o.data.vertices]
|
|
gx, gy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
|
|
bm = bmesh.new()
|
|
res = bmesh.ops.create_cube(bm, size=1.0)
|
|
bmesh.ops.scale(bm, vec=(max(xs) - min(xs) + 100, max(ys) - min(ys) + 100, 1.0), verts=res["verts"])
|
|
bmesh.ops.translate(bm, vec=(gx, gy, -0.5), verts=res["verts"])
|
|
add_obj("ground-convcol", bm, [M_GROUND], 18.0)
|
|
|
|
# parked-car / wheelie-bin markers -> empties; game.gd fills them with
|
|
# shuntable RigidBodies at load (a static GLB can't carry physics bodies)
|
|
random.Random(1982).shuffle(pp_spots)
|
|
for prefix, spots, cap in (("PP", pp_spots, PP_CAP), ("WB", wb_spots, WB_CAP)):
|
|
for i, (mx, my, yaw) in enumerate(spots[:cap]):
|
|
emp = bpy.data.objects.new("%s_%03d" % (prefix, i), None)
|
|
emp.location = (mx, my, 0.05)
|
|
emp.rotation_euler = (0, 0, yaw)
|
|
bpy.context.collection.objects.link(emp)
|
|
counts["pp"] = min(len(pp_spots), PP_CAP)
|
|
counts["wb"] = min(len(wb_spots), WB_CAP)
|
|
|
|
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)
|