Brisbane: Queen Street Mall crash-run level from OpenStreetMap
tools/build_level_osm.py extrudes OSM buildings (-col), lays road strips, Spawn + RacePath empties; osm_level_args.py computes a building-clear spawn on a named way and race-loop corners from street intersection nodes. game.gd builds Path3D from GLB RacePath empties, sanitizes spawn bases, and places bodies BEFORE add_child. Three real bugs fixed: mall canopies (building=roof) extruded as solid blocks, underground busways rendered at surface, and the km-wide ground trimesh breaking suspension raycasts (now -convcol). Cars get continuous_cd for thin building shells. tests/probe.gd is the physics raycast debugger that found it. Data (c) OpenStreetMap contributors, ODbL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c4f11cc7de
commit
d9b69e688e
15
README.md
15
README.md
@ -53,6 +53,21 @@ Downloaded models (from `shitboxes/`, gitignored + .gdignore'd) convert via:
|
||||
|
||||
Imported cars don't shed det_* panels until someone cuts their doors out in Blender.
|
||||
|
||||
## Real-world levels from OpenStreetMap
|
||||
|
||||
Real street grids, our art style, clean collision, ODbL-licensed. Fetch a bbox from the
|
||||
OSM API, convert, build (queen_st_mall was made exactly like this):
|
||||
|
||||
```
|
||||
curl -A 'BurnoutShitbox/1.0' 'https://api.openstreetmap.org/api/0.6/map?bbox=<minlon>,<minlat>,<maxlon>,<maxlat>' -o area.xml
|
||||
python3 tools/osm_xml_to_json.py area.xml area.json # see git history / osm_level_args.py
|
||||
ARGS=$(python3 tools/osm_level_args.py area.json "<spawn way name>" "<loop street 1,2,3,4>")
|
||||
Blender -b -P tools/build_level_osm.py -- area.json <level_id> <origin_lat> <origin_lon> $ARGS
|
||||
```
|
||||
|
||||
Don't reconstruct from Street View imagery (ToS + melted collision). Use photos as
|
||||
reference to hand-detail hero landmarks on the OSM base.
|
||||
|
||||
## Add a level
|
||||
|
||||
Make a folder `levels/<id>/` containing `level.glb` (or `level.tscn`). Blender convention:
|
||||
|
||||
BIN
levels/queen_st_mall/level.glb
Normal file
BIN
levels/queen_st_mall/level.glb
Normal file
Binary file not shown.
42
levels/queen_st_mall/level.glb.import
Normal file
42
levels/queen_st_mall/level.glb.import
Normal file
@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://c674y4fs837pi"
|
||||
path="res://.godot/imported/level.glb-f093d0a503a60c4e982eefdc88ad879e.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://levels/queen_st_mall/level.glb"
|
||||
dest_files=["res://.godot/imported/level.glb-f093d0a503a60c4e982eefdc88ad879e.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
40
shot_probe1.png.import
Normal file
40
shot_probe1.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://chisg1pamath8"
|
||||
path="res://.godot/imported/shot_probe1.png-7fcccbd8717c822595793ca3bec750d3.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_probe1.png"
|
||||
dest_files=["res://.godot/imported/shot_probe1.png-7fcccbd8717c822595793ca3bec750d3.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
40
shot_probe2.png.import
Normal file
40
shot_probe2.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c6iefyegaq2wn"
|
||||
path="res://.godot/imported/shot_probe2.png-80097fc680af478bf18b1d5937767200.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_probe2.png"
|
||||
dest_files=["res://.godot/imported/shot_probe2.png-80097fc680af478bf18b1d5937767200.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
40
shot_qsm.png.import
Normal file
40
shot_qsm.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://mfkte8qttss2"
|
||||
path="res://.godot/imported/shot_qsm.png-967b3acff66d7bf0bed2878528afe78c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_qsm.png"
|
||||
dest_files=["res://.godot/imported/shot_qsm.png-967b3acff66d7bf0bed2878528afe78c.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
40
shot_qsm_top.png.import
Normal file
40
shot_qsm_top.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cyrxo3cw4imnk"
|
||||
path="res://.godot/imported/shot_qsm_top.png-6e1ac5a3e8dd19f56575f328b6aac35d.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_qsm_top.png"
|
||||
dest_files=["res://.godot/imported/shot_qsm_top.png-6e1ac5a3e8dd19f56575f328b6aac35d.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@ -49,6 +49,7 @@ func _ready() -> void:
|
||||
center_of_mass_mode = RigidBody3D.CENTER_OF_MASS_MODE_CUSTOM
|
||||
center_of_mass = Vector3(0, -0.3, 0)
|
||||
angular_damp = 3.0
|
||||
continuous_cd = true # city buildings are thin trimesh shells; stop tunneling
|
||||
spawn_xform = global_transform
|
||||
var cs: CollisionShape3D = $Shape
|
||||
cs.shape = cs.shape.duplicate()
|
||||
|
||||
41
src/game.gd
41
src/game.gd
@ -40,19 +40,41 @@ func _ready() -> void:
|
||||
race_path = p
|
||||
if String(p.name).begins_with("Race"):
|
||||
break
|
||||
if race_path == null:
|
||||
# GLB levels can't carry Path3D: build one from a "RacePath" node of empties
|
||||
var rp := level.find_child("RacePath", true, false)
|
||||
if rp != null and rp.get_child_count() >= 3:
|
||||
var path := Path3D.new()
|
||||
var curve := Curve3D.new()
|
||||
for ch in rp.get_children():
|
||||
curve.add_point((ch as Node3D).global_position)
|
||||
curve.add_point((rp.get_child(0) as Node3D).global_position)
|
||||
path.curve = curve
|
||||
level.add_child(path)
|
||||
race_path = path
|
||||
|
||||
car = (load("res://src/car.tscn") as PackedScene).instantiate()
|
||||
car.stats = load(car_path)
|
||||
# place BEFORE add_child: teleporting a continuous_cd body after it enters
|
||||
# the physics world sweeps the jump as motion and hits everything en route
|
||||
if mode == "race" and race_path:
|
||||
car.transform = _grid_xform(0.0)
|
||||
else:
|
||||
var spawn := level.find_child("Spawn", true, false)
|
||||
if spawn is Node3D:
|
||||
# trust only origin + yaw: DCC-exported empties arrive with mangled bases
|
||||
var t: Transform3D = (spawn as Node3D).global_transform
|
||||
var f := -t.basis.z
|
||||
f.y = 0.0
|
||||
f = f.normalized() if f.length_squared() > 0.001 else Vector3.FORWARD
|
||||
car.transform = Transform3D(Basis.looking_at(f, Vector3.UP), Vector3(t.origin.x, 0.7, t.origin.z))
|
||||
else:
|
||||
car.position = Vector3.UP * 0.6
|
||||
add_child(car)
|
||||
var spawn := level.find_child("Spawn", true, false)
|
||||
if spawn is Node3D:
|
||||
car.global_transform = spawn.global_transform
|
||||
car.global_position += Vector3.UP * 0.6
|
||||
car.crashed.connect(_player_crash)
|
||||
car.wreck_started.connect(_player_wreck)
|
||||
|
||||
if mode == "race" and race_path:
|
||||
_grid(car, 0.0)
|
||||
laps[car] = 0
|
||||
prev_off[car] = 0.0
|
||||
var pool := Registry.cars().values()
|
||||
@ -60,10 +82,10 @@ func _ready() -> void:
|
||||
var r: Rival = (load("res://src/rival.tscn") as PackedScene).instantiate()
|
||||
r.stats = load(pool[randi() % pool.size()])
|
||||
r.path = race_path
|
||||
add_child(r)
|
||||
var off := race_path.curve.get_baked_length() - 8.0 * (i + 1)
|
||||
_grid(r, off)
|
||||
r.transform = _grid_xform(off)
|
||||
r.progress = off
|
||||
add_child(r)
|
||||
laps[r] = 0
|
||||
prev_off[r] = off
|
||||
r.wreck_started.connect(_rival_wrecked.bind(r))
|
||||
@ -129,11 +151,12 @@ func _ready() -> void:
|
||||
func _exit_tree() -> void:
|
||||
Engine.time_scale = 1.0
|
||||
|
||||
func _grid(c: Car, off: float) -> void:
|
||||
func _grid_xform(off: float) -> Transform3D:
|
||||
var L := race_path.curve.get_baked_length()
|
||||
var pos := race_path.to_global(race_path.curve.sample_baked(fposmod(off, L))) + Vector3.UP * 0.8
|
||||
var ahead := race_path.to_global(race_path.curve.sample_baked(fposmod(off + 4.0, L))) + Vector3.UP * 0.8
|
||||
c.look_at_from_position(pos, ahead, Vector3.UP)
|
||||
var dir := (ahead - pos).normalized()
|
||||
return Transform3D(Basis.looking_at(dir if dir.length_squared() > 0.001 else Vector3.FORWARD, Vector3.UP), pos)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
# near misses: close pass on live traffic at speed
|
||||
|
||||
@ -25,6 +25,7 @@ static func pick_model() -> String:
|
||||
|
||||
func _init() -> void:
|
||||
mass = 900.0
|
||||
continuous_cd = true
|
||||
freeze = true
|
||||
freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
|
||||
contact_monitor = true
|
||||
|
||||
63
tests/probe.gd
Normal file
63
tests/probe.gd
Normal file
@ -0,0 +1,63 @@
|
||||
extends SceneTree
|
||||
## Physics probe: boot a level headless, raycast forward from the spawn to see
|
||||
## which colliders exist ahead. Usage:
|
||||
## Godot --headless --path . --script tests/probe.gd -- level=queen_st_mall
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
var opts := {}
|
||||
for a in OS.get_cmdline_user_args():
|
||||
var kv := a.split("=")
|
||||
if kv.size() == 2:
|
||||
opts[kv[0]] = kv[1]
|
||||
Game.car_path = Registry.cars().values()[0]
|
||||
var levels := Registry.levels()
|
||||
Game.level_path = levels.get(opts.get("level", ""), levels.values()[0])
|
||||
Game.mode = "cruise"
|
||||
var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate()
|
||||
root.add_child(game)
|
||||
await process_frame
|
||||
var sp := game.get_child(0).find_child("Spawn", true, false)
|
||||
print("Spawn node: ", sp, " class=", sp.get_class() if sp else "nil",
|
||||
" global=", (sp as Node3D).global_position if sp is Node3D else Vector3.INF)
|
||||
print("car right after ready: ", game.car.global_position)
|
||||
for i in 10:
|
||||
var p: Vector3 = game.car.global_position
|
||||
print("tick %d: y=%.3f vy=%.2f" % [i, p.y, game.car.linear_velocity.y])
|
||||
await physics_frame
|
||||
var space := game.car.get_world_3d().direct_space_state
|
||||
var from := game.car.global_position + Vector3.UP * 0.5
|
||||
var fwd := -game.car.global_basis.z
|
||||
print("spawn at ", game.car.global_position, " facing ", fwd)
|
||||
for dist in [5.0, 200.0]:
|
||||
var q := PhysicsRayQueryParameters3D.create(from, from + fwd * dist)
|
||||
q.exclude = [game.car.get_rid()]
|
||||
var hit := space.intersect_ray(q)
|
||||
if hit:
|
||||
print("ray %dm: hit %s at %.1fm" % [dist, hit.collider.get_parent().name if hit.collider.get_parent() else hit.collider.name, from.distance_to(hit.position)])
|
||||
else:
|
||||
print("ray %dm: clear" % dist)
|
||||
for off in [Vector3.ZERO, Vector3(20, 0, -18)]:
|
||||
var down := PhysicsRayQueryParameters3D.create(from + off, from + off + Vector3.DOWN * 100.0)
|
||||
down.exclude = [game.car.get_rid()]
|
||||
var dh := space.intersect_ray(down)
|
||||
if dh:
|
||||
print("down@%s: hit %s at y=%.2f" % [off, dh.collider.get_parent().name if dh.collider.get_parent() else dh.collider.name, dh.position.y])
|
||||
else:
|
||||
print("down@%s: NOTHING within 100m" % off)
|
||||
var bodies := 0
|
||||
for n in game.find_children("*", "StaticBody3D", true, false):
|
||||
bodies += 1
|
||||
print("static bodies in level: ", bodies)
|
||||
var car := game.car
|
||||
print("basis.y = ", car.global_basis.y, " basis.z = ", car.global_basis.z)
|
||||
for w: Node3D in car.wheels:
|
||||
var wf := w.global_position
|
||||
var q2 := PhysicsRayQueryParameters3D.create(wf, wf - car.global_basis.y * Car.REST)
|
||||
q2.exclude = [car.get_rid()]
|
||||
var h2 := space.intersect_ray(q2)
|
||||
print("wheel %s from y=%.2f: %s" % [w.name, wf.y,
|
||||
("hit %s y=%.2f" % [h2.collider.get_parent().name, h2.position.y]) if h2 else "MISS"])
|
||||
quit(0)
|
||||
1
tests/probe.gd.uid
Normal file
1
tests/probe.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dlhngyd08ly5k
|
||||
@ -14,7 +14,8 @@ func _run() -> void:
|
||||
opts[kv[0]] = kv[1]
|
||||
var cars := Registry.cars()
|
||||
Game.car_path = cars.get(opts.get("car", ""), cars.values()[0])
|
||||
Game.level_path = Registry.levels().values()[0]
|
||||
var levels := Registry.levels()
|
||||
Game.level_path = levels.get(opts.get("level", ""), levels.values()[0])
|
||||
Game.mode = opts.get("mode", "cruise")
|
||||
var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate()
|
||||
root.add_child(game)
|
||||
@ -29,6 +30,14 @@ func _run() -> void:
|
||||
game.cam.look_at(game.car.global_position + Vector3.UP * 0.4)
|
||||
await process_frame
|
||||
await process_frame
|
||||
elif opts.get("cam", "") == "top":
|
||||
game.set_process(false)
|
||||
var alt := float(opts.get("alt", "150"))
|
||||
game.cam.far = 4000.0
|
||||
game.cam.global_position = game.car.global_position + Vector3(0, alt, 0)
|
||||
game.cam.look_at(game.car.global_position, Vector3.FORWARD)
|
||||
await process_frame
|
||||
await process_frame
|
||||
root.get_texture().get_image().save_png("res://" + opts.get("out", "screenshot.png"))
|
||||
print("screenshot saved")
|
||||
quit(0)
|
||||
|
||||
@ -19,7 +19,8 @@ func _run() -> void:
|
||||
assert(load(s.model_path) is PackedScene, "bad model: %s" % s.model_path)
|
||||
# datto is procedural (has det_* parts); imported models legitimately have none
|
||||
Game.car_path = cars.get("datto_120y", cars.values()[0])
|
||||
Game.level_path = levels.values()[0]
|
||||
# the carpark is the controlled arena; city levels have walls that complicate the ram test
|
||||
Game.level_path = levels.get("woolies_carpark", levels.values()[0])
|
||||
|
||||
# cruise: throttle 3s, car must move
|
||||
var game: Game = await _boot("cruise")
|
||||
|
||||
166
tools/build_level_osm.py
Normal file
166
tools/build_level_osm.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""Build a stylized city level from OpenStreetMap data (Overpass JSON, `out geom`).
|
||||
|
||||
Run: /Applications/Blender.app/Contents/MacOS/Blender -b -P tools/build_level_osm.py -- \
|
||||
<overpass.json> <level_id> <origin_lat> <origin_lon> [spawn_lat,lon,bearing_deg] [rp_lat,lon;lat,lon;...]
|
||||
|
||||
Buildings extrude to building:levels height (else a hash-seeded CBD-ish height),
|
||||
roads become dark strips, ground is one big slab. Meshes are suffixed -col so
|
||||
Godot generates static collision on import. Spawn empty + RacePath empties
|
||||
follow the game's level conventions. Exports levels/<level_id>/level.glb.
|
||||
|
||||
ponytail: flat terrain only -- cliffs/bridges are hand-built hero pieces later.
|
||||
Data (c) OpenStreetMap contributors, ODbL.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import bpy
|
||||
import bmesh
|
||||
|
||||
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 ""
|
||||
|
||||
M_LAT = 110574.0
|
||||
M_LON = 111320.0 * math.cos(math.radians(LAT0))
|
||||
|
||||
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)
|
||||
|
||||
def material(name, rgba, rough=0.8):
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = next(n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED")
|
||||
bsdf.inputs["Base Color"].default_value = rgba
|
||||
bsdf.inputs["Roughness"].default_value = rough
|
||||
return mat
|
||||
|
||||
PALETTE = [(0.75, 0.72, 0.66, 1), (0.62, 0.60, 0.58, 1), (0.55, 0.58, 0.62, 1),
|
||||
(0.70, 0.63, 0.55, 1), (0.52, 0.55, 0.50, 1), (0.66, 0.66, 0.70, 1)]
|
||||
MATS = [material("bld%d" % i, c) for i, c in enumerate(PALETTE)]
|
||||
GLASS = material("bldglass", (0.35, 0.45, 0.52, 1), rough=0.3)
|
||||
ROAD = material("road", (0.22, 0.22, 0.23, 1))
|
||||
MALL = material("mall", (0.55, 0.45, 0.38, 1)) # paved pedestrian brown
|
||||
GROUND = material("ground", (0.42, 0.42, 0.43, 1))
|
||||
|
||||
d = json.load(open(src))
|
||||
buildings = 0
|
||||
roads = 0
|
||||
|
||||
def add_mesh(name, mesh, mat):
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
obj.data.materials.append(mat)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
return obj
|
||||
|
||||
for e in d["elements"]:
|
||||
tags = e.get("tags", {})
|
||||
geom = e.get("geometry")
|
||||
if not geom:
|
||||
continue
|
||||
pts = [xy(g["lat"], g["lon"]) for g in geom]
|
||||
|
||||
if tags.get("building"):
|
||||
if len(pts) < 4:
|
||||
continue
|
||||
if tags["building"] in ("roof", "bridge"):
|
||||
continue # mall canopies / walkway bridges become solid blocks, skip
|
||||
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
|
||||
bm = bmesh.new()
|
||||
try:
|
||||
verts = [bm.verts.new((x, y, 0)) for x, y in pts[:-1]]
|
||||
face = bm.faces.new(verts)
|
||||
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)
|
||||
mesh = bpy.data.meshes.new("bld")
|
||||
bm.to_mesh(mesh)
|
||||
mat = GLASS if h > 60 else MATS[e["id"] % len(MATS)]
|
||||
add_mesh("bld_%d-col" % e["id"], mesh, mat)
|
||||
buildings += 1
|
||||
except Exception:
|
||||
pass # self-intersecting footprint, skip
|
||||
finally:
|
||||
bm.free()
|
||||
|
||||
elif tags.get("highway"):
|
||||
hw = tags["highway"]
|
||||
if hw in ("footway", "steps", "path", "cycleway", "corridor", "platform"):
|
||||
continue
|
||||
if tags.get("tunnel") == "yes" or int(tags.get("layer", "0") or 0) < 0:
|
||||
continue # underground busways/tunnels don't belong at street level
|
||||
wide = {"primary": 9.0, "secondary": 8.0, "tertiary": 7.0, "pedestrian": 10.0}.get(hw, 6.0)
|
||||
mat = MALL if hw == "pedestrian" else ROAD
|
||||
bm = bmesh.new()
|
||||
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 * wide / 2, dx / L * wide / 2
|
||||
try:
|
||||
bm.faces.new([bm.verts.new(p) for p in [
|
||||
(x1 + nx, y1 + ny, 0.03), (x1 - nx, y1 - ny, 0.03),
|
||||
(x2 - nx, y2 - ny, 0.03), (x2 + nx, y2 + ny, 0.03)]])
|
||||
except Exception:
|
||||
pass
|
||||
mesh = bpy.data.meshes.new("road")
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
add_mesh("road_%d" % e["id"], mesh, mat)
|
||||
roads += 1
|
||||
|
||||
# ground slab sized to content
|
||||
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()
|
||||
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=bm.verts)
|
||||
bmesh.ops.translate(bm, vec=(gx, gy, -0.5), verts=bm.verts)
|
||||
mesh = bpy.data.meshes.new("ground")
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
# convcol: km-wide trimesh triangles break short suspension raycasts (float precision);
|
||||
# a convex box collider is exact
|
||||
add_mesh("ground-convcol", mesh, GROUND)
|
||||
|
||||
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)) # +Y = travel dir = compass bearing
|
||||
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)
|
||||
e = bpy.data.objects.new("RP_%02d" % i, None)
|
||||
e.location = (px, py, 0.1)
|
||||
bpy.context.collection.objects.link(e)
|
||||
e.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: %d buildings, %d roads -> %s" % (buildings, roads, path))
|
||||
80
tools/osm_level_args.py
Normal file
80
tools/osm_level_args.py
Normal file
@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compute Spawn + RacePath args for build_level_osm.py from Overpass JSON.
|
||||
|
||||
Usage: osm_level_args.py <overpass.json> <spawn_way_name> <loop_street1,street2,...>
|
||||
|
||||
Spawn: a point on the named way that is NOT inside any building footprint
|
||||
(CBD malls have awnings/buildings mapped over them), bearing taken from that
|
||||
point's own segment. RacePath: intersection nodes of consecutive loop streets.
|
||||
Prints two shell-ready args: "lat,lon,bearing" and "lat,lon;lat,lon;..."
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
|
||||
data = json.load(open(sys.argv[1]))
|
||||
spawn_name = sys.argv[2]
|
||||
loop = sys.argv[3].split(",")
|
||||
|
||||
M_LAT = 110574.0
|
||||
|
||||
def xy(lat, lon, lat0):
|
||||
return (lon * 111320.0 * math.cos(math.radians(lat0)), lat * M_LAT)
|
||||
|
||||
ways = {}
|
||||
polys = []
|
||||
for e in data["elements"]:
|
||||
tags = e.get("tags", {})
|
||||
name = tags.get("name")
|
||||
if name and e.get("nodes") and e.get("geometry"):
|
||||
ways.setdefault(name, []).append(e)
|
||||
if tags.get("building") and e.get("geometry"):
|
||||
polys.append([(g["lat"], g["lon"]) for g in e["geometry"]])
|
||||
|
||||
def inside(lat, lon, poly):
|
||||
n = len(poly)
|
||||
hit = False
|
||||
for i in range(n - 1):
|
||||
(y1, x1), (y2, x2) = poly[i], poly[i + 1]
|
||||
if (y1 > lat) != (y2 > lat) and lon < (x2 - x1) * (lat - y1) / (y2 - y1) + x1:
|
||||
hit = not hit
|
||||
return hit
|
||||
|
||||
def in_any_building(lat, lon):
|
||||
return any(inside(lat, lon, p) for p in polys)
|
||||
|
||||
sw = ways.get(spawn_name)
|
||||
assert sw, "no way named %r" % spawn_name
|
||||
candidates = []
|
||||
for w in sw:
|
||||
geo = w["geometry"]
|
||||
for i in range(1, len(geo) - 1):
|
||||
g = geo[i]
|
||||
if in_any_building(g["lat"], g["lon"]):
|
||||
continue
|
||||
a, b = geo[i - 1], geo[i + 1]
|
||||
bearing = math.degrees(math.atan2(
|
||||
(b["lon"] - a["lon"]) * math.cos(math.radians(g["lat"])),
|
||||
b["lat"] - a["lat"])) % 360
|
||||
candidates.append((g["lat"], g["lon"], bearing))
|
||||
assert candidates, "every point of %r is inside a building" % spawn_name
|
||||
clat = sum(c[0] for c in candidates) / len(candidates)
|
||||
clon = sum(c[1] for c in candidates) / len(candidates)
|
||||
lat, lon, bearing = min(candidates, key=lambda c: (c[0] - clat) ** 2 + (c[1] - clon) ** 2)
|
||||
print("%.6f,%.6f,%.1f" % (lat, lon, bearing))
|
||||
|
||||
def nodes_of(name):
|
||||
out = {}
|
||||
for w in ways.get(name, []):
|
||||
for nid, g in zip(w["nodes"], w["geometry"]):
|
||||
out[nid] = (g["lat"], g["lon"])
|
||||
return out
|
||||
|
||||
corners = []
|
||||
for i, street in enumerate(loop):
|
||||
nxt = loop[(i + 1) % len(loop)]
|
||||
shared = set(nodes_of(street)) & set(nodes_of(nxt))
|
||||
assert shared, "no intersection %s x %s" % (street, nxt)
|
||||
lat, lon = nodes_of(street)[sorted(shared)[0]]
|
||||
corners.append("%.6f,%.6f" % (lat, lon))
|
||||
print(";".join(corners))
|
||||
Loading…
Reference in New Issue
Block a user