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:
type-two 2026-07-27 23:33:51 +10:00
parent c4f11cc7de
commit d9b69e688e
16 changed files with 573 additions and 11 deletions

View File

@ -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:

Binary file not shown.

View 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
View 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
View 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
View 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
View 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

View File

@ -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()

View File

@ -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

View File

@ -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
View 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
View File

@ -0,0 +1 @@
uid://dlhngyd08ly5k

View File

@ -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)

View File

@ -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
View 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
View 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))