diff --git a/README.md b/README.md index 625ebff..63a8fa5 100644 --- a/README.md +++ b/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=,,,' -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 "" "") +Blender -b -P tools/build_level_osm.py -- area.json $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//` containing `level.glb` (or `level.tscn`). Blender convention: diff --git a/levels/queen_st_mall/level.glb b/levels/queen_st_mall/level.glb new file mode 100644 index 0000000..512f2eb Binary files /dev/null and b/levels/queen_st_mall/level.glb differ diff --git a/levels/queen_st_mall/level.glb.import b/levels/queen_st_mall/level.glb.import new file mode 100644 index 0000000..c282948 --- /dev/null +++ b/levels/queen_st_mall/level.glb.import @@ -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 diff --git a/shot_probe1.png.import b/shot_probe1.png.import new file mode 100644 index 0000000..8526a19 --- /dev/null +++ b/shot_probe1.png.import @@ -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 diff --git a/shot_probe2.png.import b/shot_probe2.png.import new file mode 100644 index 0000000..299021b --- /dev/null +++ b/shot_probe2.png.import @@ -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 diff --git a/shot_qsm.png.import b/shot_qsm.png.import new file mode 100644 index 0000000..372cdf0 --- /dev/null +++ b/shot_qsm.png.import @@ -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 diff --git a/shot_qsm_top.png.import b/shot_qsm_top.png.import new file mode 100644 index 0000000..2384e68 --- /dev/null +++ b/shot_qsm_top.png.import @@ -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 diff --git a/src/car.gd b/src/car.gd index d3ed3d5..b938446 100644 --- a/src/car.gd +++ b/src/car.gd @@ -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() diff --git a/src/game.gd b/src/game.gd index 33b6679..7ae844d 100644 --- a/src/game.gd +++ b/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 diff --git a/src/traffic.gd b/src/traffic.gd index 4a32352..5bbdf53 100644 --- a/src/traffic.gd +++ b/src/traffic.gd @@ -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 diff --git a/tests/probe.gd b/tests/probe.gd new file mode 100644 index 0000000..726363d --- /dev/null +++ b/tests/probe.gd @@ -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) diff --git a/tests/probe.gd.uid b/tests/probe.gd.uid new file mode 100644 index 0000000..20ae053 --- /dev/null +++ b/tests/probe.gd.uid @@ -0,0 +1 @@ +uid://dlhngyd08ly5k diff --git a/tests/screenshot.gd b/tests/screenshot.gd index afd1651..96e83e2 100644 --- a/tests/screenshot.gd +++ b/tests/screenshot.gd @@ -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) diff --git a/tests/smoke.gd b/tests/smoke.gd index 72c1741..4c19abe 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -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") diff --git a/tools/build_level_osm.py b/tools/build_level_osm.py new file mode 100644 index 0000000..eb34fe2 --- /dev/null +++ b/tools/build_level_osm.py @@ -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 -- \ + [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.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)) diff --git a/tools/osm_level_args.py b/tools/osm_level_args.py new file mode 100644 index 0000000..b5aa38b --- /dev/null +++ b/tools/osm_level_args.py @@ -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 + +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))