"""OSM XML parsing and the local metric projection. Pure Python: no `bpy`, so this runs and tests outside Blender. """ import math import xml.etree.ElementTree as ET def tags(element): return {t.attrib.get("k", ""): t.attrib.get("v", "") for t in element.findall("tag")} def parse_osm(path): root = ET.parse(path).getroot() bounds_node = root.find("bounds") if bounds_node is None: raise RuntimeError("OSM file does not contain a bounds element") bounds = {"min_lon": float(bounds_node.attrib["minlon"]), "min_lat": float(bounds_node.attrib["minlat"]), "max_lon": float(bounds_node.attrib["maxlon"]), "max_lat": float(bounds_node.attrib["maxlat"])} nodes = {} point_features = [] for node in root.findall("node"): try: node_id = int(node.attrib["id"]) coord = (float(node.attrib["lon"]), float(node.attrib["lat"])) node_tags = tags(node) nodes[node_id] = coord if node_tags: point_features.append({"id": node.attrib.get("id", ""), "coord": coord, "tags": node_tags}) except (KeyError, ValueError): continue ways = [] for way in root.findall("way"): if way.attrib.get("action") == "delete": continue refs = [] for ref in way.findall("nd"): try: refs.append(int(ref.attrib["ref"])) except (KeyError, ValueError): pass coords = [nodes[r] for r in refs if r in nodes] if len(coords) >= 2: ways.append({"id": way.attrib.get("id", ""), "coords": coords, "tags": tags(way)}) return bounds, ways, point_features def parse_height(feature_tags, default): try: return max(0.5, float(feature_tags.get("height", default))) except ValueError: return default class Projector: """Equirectangular projection about the centre of the OSM bounds. Output is metres in a local ENU frame (X east, Y north), which is what both the Blender scene and the Cesium GLB are authored in. """ def __init__(self, bounds): self.bounds = bounds self.lon0 = (bounds["min_lon"] + bounds["max_lon"]) / 2 self.lat0 = (bounds["min_lat"] + bounds["max_lat"]) / 2 self.m_per_lat = 111320.0 self.m_per_lon = 111320.0 * math.cos(math.radians(self.lat0)) def xy(self, lon_lat): lon, lat = lon_lat return ((lon - self.lon0) * self.m_per_lon, (lat - self.lat0) * self.m_per_lat) def inside(self, lon_lat, pad=0.00035): lon, lat = lon_lat b = self.bounds return (b["min_lon"] - pad <= lon <= b["max_lon"] + pad and b["min_lat"] - pad <= lat <= b["max_lat"] + pad) def ring(self, coords): return [self.xy(c) for c in coords]