Spaces:
Running
Running
Added the possibility to switch to local UTM projection & getting roads with smoother footprint
f1b90ef
verified
| """ | |
| OSM 3D Environment Generator - Interactive Gradio App | |
| Generate and visualize 3D building blocks and roads from OpenStreetMap data | |
| """ | |
| import gradio as gr | |
| import requests | |
| import math | |
| import time | |
| import os | |
| from datetime import datetime | |
| from typing import Tuple, Optional, Dict, List, Union | |
| from dotenv import load_dotenv | |
| import mercantile | |
| import numpy as np | |
| from PIL import Image | |
| from io import BytesIO | |
| import tempfile | |
| import geopandas as gpd | |
| from shapely.geometry import shape, mapping, LineString, Polygon, MultiPolygon # if working with raw GeoJSON | |
| from pyproj import CRS, Transformer # optional, but part of geopandas | |
| import json | |
| import urllib.parse | |
| # Constants | |
| OVERPASS_URL = "http://overpass-api.de/api/interpreter" | |
| MAX_RETRIES = 3 | |
| MAPBOX_TOKEN = os.getenv("MAPBOX_TOKEN", "") | |
| def km_to_degrees(km: float, lat: float) -> Tuple[float, float]: | |
| """Convert kilometers to degrees at given latitude.""" | |
| lat_offset = km / 111.0 | |
| lon_offset = km / (111.0 * math.cos(math.radians(lat))) | |
| return lat_offset, lon_offset | |
| def latlon_to_meters(lat: float, lon: float, center_lat: float, center_lon: float) -> Tuple[float, float]: | |
| """Convert lat/lon to meters from center point.""" | |
| x = (lon - center_lon) * 111000 * math.cos(math.radians(center_lat)) | |
| y = (lat - center_lat) * 111000 | |
| return x, y | |
| def fetch_osm_data(center_lat: float, center_lon: float, square_size_km: float) -> Optional[Dict]: | |
| """Fetch buildings and roads from OSM.""" | |
| half_size_km = square_size_km / 2 | |
| lat_offset, lon_offset = km_to_degrees(half_size_km, center_lat) | |
| bbox = { | |
| 'south': center_lat - lat_offset, | |
| 'north': center_lat + lat_offset, | |
| 'west': center_lon - lon_offset, | |
| 'east': center_lon + lon_offset | |
| } | |
| query = f""" | |
| [out:json][timeout:180]; | |
| ( | |
| way["building"]({bbox['south']},{bbox['west']},{bbox['north']},{bbox['east']}); | |
| relation["building"]({bbox['south']},{bbox['west']},{bbox['north']},{bbox['east']}); | |
| way["highway"]({bbox['south']},{bbox['west']},{bbox['north']},{bbox['east']}); | |
| ); | |
| out body; | |
| >; | |
| out skel qt; | |
| """ | |
| # Retry logic | |
| for attempt in range(MAX_RETRIES): | |
| try: | |
| response = requests.post(OVERPASS_URL, data={'data': query}, timeout=200) | |
| if response.status_code == 200: | |
| return response.json() | |
| elif response.status_code in [429, 504] and attempt < MAX_RETRIES - 1: | |
| time.sleep((attempt + 1) * 10) | |
| else: | |
| return None | |
| except requests.exceptions.Timeout: | |
| if attempt < MAX_RETRIES - 1: | |
| time.sleep(10) | |
| else: | |
| return None | |
| return None | |
| def get_building_height(tags: Dict) -> float: | |
| """Extract or estimate building height from OSM tags.""" | |
| # Direct height tag | |
| if 'height' in tags: | |
| try: | |
| return float(tags['height'].replace('m', '').replace('M', '').strip()) | |
| except: | |
| pass | |
| if 'building:height' in tags: | |
| try: | |
| return float(tags['building:height'].replace('m', '').replace('M', '').strip()) | |
| except: | |
| pass | |
| # Calculate from levels | |
| if 'building:levels' in tags: | |
| try: | |
| return float(tags['building:levels']) * 3.5 | |
| except: | |
| pass | |
| if 'levels' in tags: | |
| try: | |
| return float(tags['levels']) * 3.5 | |
| except: | |
| pass | |
| # Height estimates by building type | |
| building_type = tags.get('building', 'yes').lower() | |
| height_map = { | |
| 'cathedral': 30, 'church': 20, 'commercial': 12, 'office': 15, | |
| 'hotel': 20, 'apartments': 18, 'residential': 9, 'house': 7, | |
| 'garage': 3, 'shed': 3, 'tower': 40, 'stadium': 25 | |
| } | |
| return height_map.get(building_type, 9.0) | |
| def get_road_width(road_type: str) -> float: | |
| """Get road width in meters based on highway type.""" | |
| width_map = { | |
| 'motorway': 12.0, 'trunk': 10.0, 'primary': 8.0, 'secondary': 7.0, | |
| 'tertiary': 6.0, 'residential': 5.0, 'service': 3.0, 'footway': 2.0, | |
| 'path': 1.5, 'cycleway': 2.0, 'unclassified': 5.0 | |
| } | |
| return width_map.get(road_type, 4.0) | |
| # ============================================================ | |
| # SMOOTH ROAD FOOTPRINT ALGORITHM (V2) | |
| # ============================================================ | |
| def offset_polyline(points: List[Tuple[float, float]], distance: float) -> List[Tuple[float, float]]: | |
| """ | |
| Offset a polyline by perpendicular distance. | |
| Positive distance = left side, negative = right side. | |
| Uses averaged normals at interior points for smooth corners. | |
| """ | |
| if len(points) < 2: | |
| return points | |
| result = [] | |
| for i in range(len(points)): | |
| if i == 0: | |
| # First point: use direction to next | |
| dx = points[1][0] - points[0][0] | |
| dy = points[1][1] - points[0][1] | |
| elif i == len(points) - 1: | |
| # Last point: use direction from previous | |
| dx = points[i][0] - points[i-1][0] | |
| dy = points[i][1] - points[i-1][1] | |
| else: | |
| # Middle: average incoming and outgoing directions | |
| dx1 = points[i][0] - points[i-1][0] | |
| dy1 = points[i][1] - points[i-1][1] | |
| len1 = math.sqrt(dx1**2 + dy1**2) | |
| dx2 = points[i+1][0] - points[i][0] | |
| dy2 = points[i+1][1] - points[i][1] | |
| len2 = math.sqrt(dx2**2 + dy2**2) | |
| if len1 > 0.0001: | |
| dx1, dy1 = dx1/len1, dy1/len1 | |
| if len2 > 0.0001: | |
| dx2, dy2 = dx2/len2, dy2/len2 | |
| dx = dx1 + dx2 | |
| dy = dy1 + dy2 | |
| length = math.sqrt(dx**2 + dy**2) | |
| if length < 0.0001: | |
| result.append(points[i]) | |
| continue | |
| dx /= length | |
| dy /= length | |
| # Perpendicular (90° left) | |
| perp_x = -dy * distance | |
| perp_y = dx * distance | |
| result.append((points[i][0] + perp_x, points[i][1] + perp_y)) | |
| return result | |
| def create_arc_points(center: Tuple[float, float], radius: float, | |
| start_angle: float, end_angle: float, | |
| segments: int = 8) -> List[Tuple[float, float]]: | |
| """Create points along an arc from start_angle to end_angle.""" | |
| points = [] | |
| # Normalize angle difference to take shorter path | |
| angle_diff = end_angle - start_angle | |
| while angle_diff > math.pi: | |
| angle_diff -= 2 * math.pi | |
| while angle_diff < -math.pi: | |
| angle_diff += 2 * math.pi | |
| for i in range(segments + 1): | |
| t = i / segments | |
| angle = start_angle + angle_diff * t | |
| x = center[0] + radius * math.cos(angle) | |
| y = center[1] + radius * math.sin(angle) | |
| points.append((x, y)) | |
| return points | |
| def get_direction_at_endpoint(points: List[Tuple[float, float]], at_start: bool) -> Tuple[float, float]: | |
| """Get normalized direction vector at start or end of polyline.""" | |
| if at_start: | |
| dx = points[1][0] - points[0][0] | |
| dy = points[1][1] - points[0][1] | |
| else: | |
| n = len(points) | |
| dx = points[n-1][0] - points[n-2][0] | |
| dy = points[n-1][1] - points[n-2][1] | |
| length = math.sqrt(dx**2 + dy**2) | |
| if length > 0.0001: | |
| return (dx/length, dy/length) | |
| return (1, 0) | |
| def find_road_intersections(roads: List[Dict], tolerance: float = 1.0) -> Dict[Tuple[float, float], List[Dict]]: | |
| """ | |
| Find where road endpoints meet. | |
| Returns dict: intersection_center -> list of road info at that intersection. | |
| """ | |
| # Collect all endpoints | |
| endpoints = [] | |
| for road in roads: | |
| points = road['points'] | |
| if len(points) < 2: | |
| continue | |
| endpoints.append({ | |
| 'point': points[0], | |
| 'road': road, | |
| 'position': 'start', | |
| 'direction': get_direction_at_endpoint(points, True) | |
| }) | |
| endpoints.append({ | |
| 'point': points[-1], | |
| 'road': road, | |
| 'position': 'end', | |
| 'direction': get_direction_at_endpoint(points, False) | |
| }) | |
| # Group nearby endpoints | |
| intersections = {} | |
| used = set() | |
| for i, ep1 in enumerate(endpoints): | |
| if i in used: | |
| continue | |
| group = [ep1] | |
| center_x = ep1['point'][0] | |
| center_y = ep1['point'][1] | |
| for j, ep2 in enumerate(endpoints): | |
| if j <= i or j in used: | |
| continue | |
| dist = math.sqrt((ep1['point'][0] - ep2['point'][0])**2 + | |
| (ep1['point'][1] - ep2['point'][1])**2) | |
| if dist < tolerance: | |
| group.append(ep2) | |
| center_x += ep2['point'][0] | |
| center_y += ep2['point'][1] | |
| used.add(j) | |
| if len(group) >= 2: | |
| center = (center_x / len(group), center_y / len(group)) | |
| intersections[center] = group | |
| used.add(i) | |
| return intersections | |
| class RoadMeshBuilder: | |
| """Builds proper mesh geometry for smooth road footprints.""" | |
| def __init__(self, base_z: float = 0.1): | |
| self.vertices = [] | |
| self.faces = [] | |
| self.z_height = base_z | |
| def add_vertex(self, x: float, y: float, z: float = None) -> int: | |
| """Add a vertex and return its index (1-based for OBJ).""" | |
| idx = len(self.vertices) + 1 # OBJ is 1-indexed | |
| if z is None: | |
| z = self.z_height | |
| self.vertices.append((x, y, z)) | |
| return idx | |
| def add_triangle(self, i0: int, i1: int, i2: int): | |
| """Add a triangle face (indices are 1-based).""" | |
| self.faces.append((i0, i1, i2)) | |
| def add_quad_strip(self, left_points: List[Tuple[float, float]], | |
| right_points: List[Tuple[float, float]], | |
| z_values_left: List[float] = None, | |
| z_values_right: List[float] = None): | |
| """ | |
| Add geometry for a quad strip between two parallel edge lines. | |
| This creates proper road ribbon triangulation. | |
| """ | |
| n = min(len(left_points), len(right_points)) | |
| if n < 2: | |
| return | |
| # Add all vertices first | |
| left_indices = [] | |
| right_indices = [] | |
| for i in range(n): | |
| z_left = z_values_left[i] if z_values_left else self.z_height | |
| z_right = z_values_right[i] if z_values_right else self.z_height | |
| left_indices.append(self.add_vertex(left_points[i][0], left_points[i][1], z_left)) | |
| right_indices.append(self.add_vertex(right_points[i][0], right_points[i][1], z_right)) | |
| # Create triangles for each quad | |
| for i in range(n - 1): | |
| li = left_indices[i] | |
| li1 = left_indices[i + 1] | |
| ri = right_indices[i] | |
| ri1 = right_indices[i + 1] | |
| # Two triangles per quad | |
| self.add_triangle(li, li1, ri1) | |
| self.add_triangle(li, ri1, ri) | |
| def add_semicircle_cap(self, center: Tuple[float, float], radius: float, | |
| direction: Tuple[float, float], z: float = None): | |
| """Add a semicircle end cap at dead ends.""" | |
| if z is None: | |
| z = self.z_height | |
| dir_angle = math.atan2(direction[1], direction[0]) | |
| start_angle = dir_angle - math.pi/2 | |
| end_angle = dir_angle + math.pi/2 | |
| arc_points = create_arc_points(center, radius, start_angle, end_angle, 8) | |
| # Fan triangulation from center | |
| center_idx = self.add_vertex(center[0], center[1], z) | |
| arc_indices = [] | |
| for pt in arc_points: | |
| arc_indices.append(self.add_vertex(pt[0], pt[1], z)) | |
| for i in range(len(arc_indices) - 1): | |
| self.add_triangle(center_idx, arc_indices[i], arc_indices[i + 1]) | |
| def add_fillet_arc(self, center: Tuple[float, float], | |
| point1: Tuple[float, float], point2: Tuple[float, float], | |
| radius: float, z: float = None): | |
| """Add a fillet arc connecting two edge endpoints at an intersection.""" | |
| if z is None: | |
| z = self.z_height | |
| angle1 = math.atan2(point1[1] - center[1], point1[0] - center[0]) | |
| angle2 = math.atan2(point2[1] - center[1], point2[0] - center[0]) | |
| arc_points = create_arc_points(center, radius, angle1, angle2, 6) | |
| if len(arc_points) < 2: | |
| return | |
| center_idx = self.add_vertex(center[0], center[1], z) | |
| arc_indices = [] | |
| for pt in arc_points: | |
| arc_indices.append(self.add_vertex(pt[0], pt[1], z)) | |
| for i in range(len(arc_indices) - 1): | |
| self.add_triangle(center_idx, arc_indices[i], arc_indices[i + 1]) | |
| def generate_smooth_roads(roads_data: List[Dict], elevation_data, | |
| get_elevation_func, use_terrain: bool = True) -> Tuple[List, List]: | |
| """ | |
| Generate smooth road footprint geometry using the V2 algorithm. | |
| Args: | |
| roads_data: List of dicts with 'points' (list of (x,y) tuples), 'width', 'type', 'lat_lon_points' | |
| elevation_data: Elevation grid data tuple | |
| get_elevation_func: Function to get elevation at a lat/lon point | |
| use_terrain: If True, use elevation data; if False, all roads at z=0.2 | |
| Returns: | |
| (vertices, faces) lists for OBJ export | |
| """ | |
| if not roads_data: | |
| return [], [] | |
| # Find intersections | |
| intersections = find_road_intersections(roads_data, tolerance=1.0) | |
| # Track which road endpoints are at intersections, and store their Z values | |
| intersection_endpoints = set() | |
| intersection_z_values = {} # (road_id, position) -> z_value | |
| for center, group in intersections.items(): | |
| for ep in group: | |
| key = (id(ep['road']), ep['position']) | |
| intersection_endpoints.add(key) | |
| # Base Z when terrain is disabled | |
| flat_z = 0.2 | |
| builder = RoadMeshBuilder(base_z=flat_z) | |
| # First pass: calculate Z values for all road endpoints | |
| road_z_values = {} # road_id -> {'start': z, 'end': z, 'all': [z1, z2, ...]} | |
| for road in roads_data: | |
| points = road['points'] | |
| lat_lon_points = road.get('lat_lon_points', []) | |
| if len(points) < 2: | |
| continue | |
| # Get Z values for each point | |
| z_values = [] | |
| for i, pt in enumerate(points): | |
| if use_terrain and elevation_data and i < len(lat_lon_points): | |
| lat, lon = lat_lon_points[i] | |
| z = get_elevation_func(lat, lon, elevation_data) + 0.2 | |
| else: | |
| z = flat_z | |
| z_values.append(z) | |
| road_z_values[id(road)] = { | |
| 'start': z_values[0], | |
| 'end': z_values[-1], | |
| 'all': z_values | |
| } | |
| # Process each road | |
| for road in roads_data: | |
| points = road['points'] | |
| width = road['width'] | |
| if len(points) < 2: | |
| continue | |
| half_width = width / 2 | |
| # Create offset lines | |
| left_edge = offset_polyline(points, half_width) | |
| right_edge = offset_polyline(points, -half_width) | |
| # Get Z values | |
| z_info = road_z_values.get(id(road), {'all': [flat_z] * len(points)}) | |
| z_values = z_info['all'] | |
| # Add the main quad strip | |
| builder.add_quad_strip(left_edge, right_edge, z_values, z_values) | |
| # Check if start is a dead end | |
| start_key = (id(road), 'start') | |
| if start_key not in intersection_endpoints: | |
| center = points[0] | |
| direction = get_direction_at_endpoint(points, True) | |
| z = z_values[0] if z_values else flat_z | |
| builder.add_semicircle_cap(center, half_width, (-direction[0], -direction[1]), z) | |
| # Check if end is a dead end | |
| end_key = (id(road), 'end') | |
| if end_key not in intersection_endpoints: | |
| center = points[-1] | |
| direction = get_direction_at_endpoint(points, False) | |
| z = z_values[-1] if z_values else flat_z | |
| builder.add_semicircle_cap(center, half_width, direction, z) | |
| # Process intersections - add fillets | |
| for center, group in intersections.items(): | |
| if len(group) < 2: | |
| continue | |
| # Calculate intersection Z as average of all connected road endpoints | |
| intersection_z_list = [] | |
| for ep in group: | |
| road = ep['road'] | |
| z_info = road_z_values.get(id(road), {'start': flat_z, 'end': flat_z}) | |
| if ep['position'] == 'start': | |
| intersection_z_list.append(z_info['start']) | |
| else: | |
| intersection_z_list.append(z_info['end']) | |
| intersection_z = sum(intersection_z_list) / len(intersection_z_list) if intersection_z_list else flat_z | |
| # Get edge info for each road at this intersection | |
| road_edges = [] | |
| for ep in group: | |
| road = ep['road'] | |
| points = road['points'] | |
| width = road['width'] | |
| half_width = width / 2 | |
| left_edge = offset_polyline(points, half_width) | |
| right_edge = offset_polyline(points, -half_width) | |
| if ep['position'] == 'start': | |
| left_pt = left_edge[0] | |
| right_pt = right_edge[0] | |
| direction = ep['direction'] | |
| else: | |
| left_pt = left_edge[-1] | |
| right_pt = right_edge[-1] | |
| direction = (-ep['direction'][0], -ep['direction'][1]) | |
| angle = math.atan2(-direction[1], -direction[0]) | |
| road_edges.append({ | |
| 'road': road, | |
| 'position': ep['position'], | |
| 'left_pt': left_pt, | |
| 'right_pt': right_pt, | |
| 'angle': angle, | |
| 'width': width | |
| }) | |
| # Sort roads by angle around intersection | |
| road_edges.sort(key=lambda x: x['angle']) | |
| # Connect adjacent roads with fillets (using intersection Z) | |
| for i in range(len(road_edges)): | |
| r1 = road_edges[i] | |
| r2 = road_edges[(i + 1) % len(road_edges)] | |
| radius = (r1['width'] + r2['width']) / 4 | |
| builder.add_fillet_arc(center, r1['right_pt'], r2['left_pt'], radius, intersection_z) | |
| return builder.vertices, builder.faces | |
| def get_utm_zone(lat: float, lon: float) -> int: | |
| """Determine UTM zone from latitude and longitude.""" | |
| zone = int((lon + 180) / 6) + 1 | |
| return zone | |
| def get_utm_epsg(lat: float, lon: float) -> int: | |
| """Get EPSG code for UTM zone based on latitude and longitude.""" | |
| zone = get_utm_zone(lat, lon) | |
| # Northern hemisphere: 32600 + zone, Southern hemisphere: 32700 + zone | |
| if lat >= 0: | |
| return 32600 + zone | |
| else: | |
| return 32700 + zone | |
| def fetch_elevation_grid(south: float, north: float, west: float, east: float) -> Tuple[Optional[np.ndarray], float, float, float, float]: | |
| """ | |
| Fetch elevation data from Mapbox Terrain-RGB for the given bbox. | |
| Returns: (elevation_grid, min_lat, min_lon, lat_res, lon_res) | |
| """ | |
| # Use zoom level 14 for a good balance of resolution and tile count | |
| ZOOM = 14 | |
| # Get the tiles covering the bbox | |
| tiles = list(mercantile.tiles(west, south, east, north, ZOOM)) | |
| if not tiles: | |
| return None, 0, 0, 0, 0 | |
| # Determine the bounds of the tile grid | |
| min_x = min(t.x for t in tiles) | |
| max_x = max(t.x for t in tiles) | |
| min_y = min(t.y for t in tiles) | |
| max_y = max(t.y for t in tiles) | |
| width_tiles = max_x - min_x + 1 | |
| height_tiles = max_y - min_y + 1 | |
| # Create a composite image | |
| tile_size = 256 | |
| composite = Image.new('RGB', (width_tiles * tile_size, height_tiles * tile_size)) | |
| for tile in tiles: | |
| url = f"https://api.mapbox.com/v4/mapbox.terrain-rgb/{tile.z}/{tile.x}/{tile.y}.pngraw?access_token={MAPBOX_TOKEN}" | |
| try: | |
| response = requests.get(url, stream=True) | |
| if response.status_code == 200: | |
| img = Image.open(BytesIO(response.content)) | |
| # Paste into composite | |
| x_offset = (tile.x - min_x) * tile_size | |
| y_offset = (tile.y - min_y) * tile_size | |
| composite.paste(img, (x_offset, y_offset)) | |
| except Exception as e: | |
| print(f"Error fetching tile {tile}: {e}") | |
| return None, 0, 0, 0, 0 | |
| # Convert to numpy array | |
| data = np.array(composite) | |
| # Decode height: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) | |
| r = data[:, :, 0].astype(np.float32) | |
| g = data[:, :, 1].astype(np.float32) | |
| b = data[:, :, 2].astype(np.float32) | |
| elevation = -10000 + ((r * 256 * 256 + g * 256 + b) * 0.1) | |
| # Calculate bounds of the composite image | |
| # Top-left of the top-left tile | |
| ul_tile = mercantile.Tile(min_x, min_y, ZOOM) | |
| ul_bounds = mercantile.bounds(ul_tile) | |
| max_lat_grid = ul_bounds.north | |
| min_lon_grid = ul_bounds.west | |
| # Bottom-right of the bottom-right tile | |
| lr_tile = mercantile.Tile(max_x, max_y, ZOOM) | |
| lr_bounds = mercantile.bounds(lr_tile) | |
| min_lat_grid = lr_bounds.south | |
| max_lon_grid = lr_bounds.east | |
| # Calculate resolution (degrees per pixel) | |
| lat_span = max_lat_grid - min_lat_grid | |
| lon_span = max_lon_grid - min_lon_grid | |
| pixel_height, pixel_width = elevation.shape | |
| lat_res = lat_span / pixel_height | |
| lon_res = lon_span / pixel_width | |
| # Crop to the requested bbox | |
| # Find indices | |
| start_row = int((max_lat_grid - north) / lat_res) | |
| end_row = int((max_lat_grid - south) / lat_res) | |
| start_col = int((west - min_lon_grid) / lon_res) | |
| end_col = int((east - min_lon_grid) / lon_res) | |
| # Clamp indices | |
| start_row = max(0, start_row) | |
| end_row = min(pixel_height, end_row) | |
| start_col = max(0, start_col) | |
| end_col = min(pixel_width, end_col) | |
| cropped_elevation = elevation[start_row:end_row, start_col:end_col] | |
| # Recalculate the precise bounds of the cropped area | |
| cropped_max_lat = max_lat_grid - start_row * lat_res | |
| cropped_min_lon = min_lon_grid + start_col * lon_res | |
| return cropped_elevation, cropped_max_lat, cropped_min_lon, lat_res, lon_res | |
| def get_elevation_at_point(lat: float, lon: float, elevation_data: Tuple) -> float: | |
| """Sample elevation from the grid at a specific point. Clamps to edge if out of bounds.""" | |
| grid, max_lat, min_lon, lat_res, lon_res = elevation_data | |
| if grid is None: | |
| return 0.0 | |
| row = int((max_lat - lat) / lat_res) | |
| col = int((lon - min_lon) / lon_res) | |
| rows, cols = grid.shape | |
| # Clamp to valid range to avoid dropping to zero at edges | |
| row = max(0, min(row, rows - 1)) | |
| col = max(0, min(col, cols - 1)) | |
| return float(grid[row, col]) | |
| def generate_obj_file(center_lat: float, center_lon: float, square_size_km: float, layers: List[str] = ["Buildings", "Roads"], | |
| contour_interval: float = 0, use_utm_projection: bool = False) -> Tuple[Optional[str], Optional[str], str]: | |
| """Generate OBJ file with buildings, roads (as polygon footprints), and topography. | |
| Args: | |
| center_lat: Latitude of center | |
| center_lon: Longitude of center | |
| square_size_km: Size of square area in km | |
| layers: List of layers to include | |
| contour_interval: Elevation interval for contour lines in meters (0 = disabled) | |
| use_utm_projection: If True, reproject all geometry to UTM | |
| """ | |
| include_buildings = "Buildings" in layers | |
| include_roads = "Roads" in layers | |
| include_terrain = "Terrain" in layers | |
| # Determine UTM EPSG if reprojection is requested | |
| utm_epsg = None | |
| if use_utm_projection: | |
| utm_epsg = get_utm_epsg(center_lat, center_lon) | |
| utm_zone = get_utm_zone(center_lat, center_lon) | |
| status = f"UTM Projection Enabled: Zone {utm_zone} (EPSG:{utm_epsg})\n" | |
| else: | |
| status = "" | |
| # Validate inputs | |
| if not (-90 <= center_lat <= 90): | |
| return None, None, "Error: Latitude must be between -90 and 90" | |
| if not (-180 <= center_lon <= 180): | |
| return None, None, "Error: Longitude must be between -180 and 180" | |
| if not (0.5 <= square_size_km <= 5): | |
| return None, None, "Error: Square size must be between 0.5 and 5 km" | |
| status += f"Fetching OSM data for {center_lat}, {center_lon} ({square_size_km}km)...\n" | |
| half_size_km = square_size_km / 2 | |
| lat_offset, lon_offset = km_to_degrees(half_size_km, center_lat) | |
| south = center_lat - lat_offset | |
| north = center_lat + lat_offset | |
| west = center_lon - lon_offset | |
| east = center_lon + lon_offset | |
| # Fetch Elevation Data (needed for terrain OR for placing objects on terrain) | |
| elevation_data = None | |
| if include_terrain or include_buildings or include_roads: | |
| status += "Fetching elevation data...\n" | |
| elevation_grid, max_lat_grid, min_lon_grid, lat_res, lon_res = fetch_elevation_grid(south, north, west, east) | |
| if elevation_grid is not None: | |
| elevation_data = (elevation_grid, max_lat_grid, min_lon_grid, lat_res, lon_res) | |
| status += f"Received elevation grid: {elevation_grid.shape}\n" | |
| else: | |
| status += "Warning: Failed to fetch elevation data. Using flat terrain.\n" | |
| # Fetch OSM Data | |
| data = None | |
| if include_buildings or include_roads: | |
| data = fetch_osm_data(center_lat, center_lon, square_size_km) | |
| if not data: | |
| status += "Failed to fetch OSM data (or none found)\n" | |
| else: | |
| status += f"Received {len(data['elements'])} OSM elements\n" | |
| # Parse data | |
| nodes = {} | |
| buildings = [] | |
| roads = [] | |
| if data: | |
| for element in data['elements']: | |
| if element['type'] == 'node': | |
| nodes[element['id']] = (element['lat'], element['lon']) | |
| elif element['type'] == 'way' and 'tags' in element: | |
| if include_buildings and 'building' in element['tags'] and 'nodes' in element: | |
| height = get_building_height(element['tags']) | |
| buildings.append({'nodes': element['nodes'], 'height': height}) | |
| elif include_roads and 'highway' in element['tags'] and 'nodes' in element: | |
| roads.append({'nodes': element['nodes'], 'type': element['tags']['highway']}) | |
| status += f"Found {len(buildings)} buildings\nFound {len(roads)} road centerlines\n" | |
| if not buildings and not roads and not include_terrain: | |
| return None, None, status + "Nothing to generate" | |
| # Create transformer once for UTM projection | |
| transformer_to_utm = None | |
| if use_utm_projection: | |
| transformer_to_utm = Transformer.from_crs("EPSG:4326", f"EPSG:{utm_epsg}", always_xy=True) | |
| # Prepare roads data for smooth footprint generation | |
| roads_with_points = [] | |
| if include_roads and roads: | |
| for road in roads: | |
| road_type = road.get('type', 'residential') | |
| width = get_road_width(road_type) | |
| points = [] # Local coordinates (x, y) | |
| lat_lon_points = [] # Original lat/lon for elevation lookup | |
| for node_id in road['nodes']: | |
| if node_id in nodes: | |
| lat, lon = nodes[node_id] | |
| lat_lon_points.append((lat, lon)) | |
| if use_utm_projection: | |
| utm_x, utm_y = transformer_to_utm.transform(lon, lat) | |
| # Store raw UTM coords, will center later | |
| points.append((utm_x, utm_y)) | |
| else: | |
| x, y = latlon_to_meters(lat, lon, center_lat, center_lon) | |
| points.append((x, y)) | |
| if len(points) >= 2: | |
| roads_with_points.append({ | |
| 'points': points, | |
| 'width': width, | |
| 'type': road_type, | |
| 'lat_lon_points': lat_lon_points | |
| }) | |
| # Generate files in a local directory for proper downloads | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| base_name = f"area_{square_size_km}km_{timestamp}" | |
| # Use temp directory - Gradio will handle cleanup | |
| #output_dir = tempfile.gettempdir() | |
| # Create output directory if it doesn't exist | |
| output_dir = "generated_models" | |
| os.makedirs(output_dir, exist_ok=True) | |
| obj_file = os.path.join(output_dir, f"{base_name}.obj") | |
| mtl_file = os.path.join(output_dir, f"{base_name}.mtl") | |
| status += "Generating 3D model...\n" | |
| # Write OBJ file | |
| mtl_filename = os.path.basename(mtl_file) # Use only filename, not full path | |
| with open(obj_file, 'w') as f: | |
| f.write(f"# OSM 3D Export - {square_size_km}km x {square_size_km}km\n") | |
| f.write(f"# Center: {center_lat}, {center_lon}\n") | |
| f.write(f"mtllib {mtl_filename}\n\n") | |
| # Normals | |
| f.write("vn 0.0 0.0 -1.0\n") # Bottom | |
| f.write("vn 0.0 0.0 1.0\n") # Top | |
| f.write("\n") | |
| vertex_count = 0 | |
| # Calculate center point in UTM for offset (to keep coordinates manageable) | |
| center_utm_x, center_utm_y = 0, 0 | |
| if use_utm_projection: | |
| # Reuse transformer if already created, otherwise create it | |
| if transformer_to_utm is None: | |
| transformer_to_utm = Transformer.from_crs("EPSG:4326", f"EPSG:{utm_epsg}", always_xy=True) | |
| center_utm_x, center_utm_y = transformer_to_utm.transform(center_lon, center_lat) | |
| # --- TERRAIN GENERATION --- | |
| if include_terrain and elevation_data: | |
| f.write("g Terrain\nusemtl Ground\n") | |
| grid, max_lat, min_lon, lat_res, lon_res = elevation_data | |
| rows, cols = grid.shape | |
| # Use step 1 for maximum quality/detail | |
| step = 1 | |
| # Create vertices | |
| # We need to map grid indices back to meters relative to center | |
| grid_vertices = {} # (row, col) -> vertex_index | |
| for r in range(0, rows, step): | |
| for c in range(0, cols, step): | |
| lat = max_lat - r * lat_res | |
| lon = min_lon + c * lon_res | |
| if use_utm_projection: | |
| # Convert lat/lon to UTM and center | |
| utm_x, utm_y = transformer_to_utm.transform(lon, lat) | |
| x = utm_x - center_utm_x | |
| y = utm_y - center_utm_y | |
| else: | |
| # Convert lat/lon to local meters | |
| x, y = latlon_to_meters(lat, lon, center_lat, center_lon) | |
| z = float(grid[r, c]) | |
| f.write(f"v {x:.2f} {y:.2f} {z:.2f}\n") | |
| vertex_count += 1 | |
| grid_vertices[(r, c)] = vertex_count | |
| # Create faces | |
| for r in range(0, rows - step, step): | |
| for c in range(0, cols - step, step): | |
| # v1 v2 | |
| # v3 v4 | |
| v1 = grid_vertices.get((r, c)) | |
| v2 = grid_vertices.get((r, c + step)) | |
| v3 = grid_vertices.get((r + step, c)) | |
| v4 = grid_vertices.get((r + step, c + step)) | |
| if v1 and v2 and v3 and v4: | |
| # Two triangles | |
| f.write(f"f {v1} {v3} {v2}\n") | |
| f.write(f"f {v2} {v3} {v4}\n") | |
| f.write("\n") | |
| # --- CONTOUR LINES --- | |
| if contour_interval > 0: | |
| f.write("g Contours\nusemtl Contour\n") | |
| min_elev = float(grid.min()) | |
| max_elev = float(grid.max()) | |
| # Generate contour lines at each interval | |
| contour_levels = np.arange( | |
| np.floor(min_elev / contour_interval) * contour_interval, | |
| np.ceil(max_elev / contour_interval) * contour_interval + contour_interval, | |
| contour_interval | |
| ) | |
| # Track first point of each contour level for label placement | |
| contour_label_points = {} | |
| for level in contour_levels: | |
| level_points = [] | |
| # Simple contour extraction using marching squares concept | |
| # For each grid cell, check if contour passes through | |
| for r in range(0, rows - 1, 1): # Use step 1 for accurate contours | |
| for c in range(0, cols - 1, 1): | |
| # Get the 4 corners of this cell | |
| z1 = grid[r, c] | |
| z2 = grid[r, c + 1] | |
| z3 = grid[r + 1, c] | |
| z4 = grid[r + 1, c + 1] | |
| # Check if contour crosses this cell | |
| zmin = min(z1, z2, z3, z4) | |
| zmax = max(z1, z2, z3, z4) | |
| if zmin <= level <= zmax: | |
| # Contour passes through - create a line segment | |
| # Simple approach: if level is between corners, interpolate | |
| points = [] | |
| # Check each edge | |
| # Top edge (r, c) to (r, c+1) | |
| if (z1 <= level <= z2) or (z2 <= level <= z1): | |
| if z1 != z2: | |
| t = (level - z1) / (z2 - z1) | |
| lat = max_lat - r * lat_res | |
| lon = min_lon + (c + t) * lon_res | |
| if use_utm_projection: | |
| utm_x, utm_y = transformer_to_utm.transform(lon, lat) | |
| x = utm_x - center_utm_x | |
| y = utm_y - center_utm_y | |
| else: | |
| x, y = latlon_to_meters(lat, lon, center_lat, center_lon) | |
| points.append((x, y, level)) | |
| # Right edge (r, c+1) to (r+1, c+1) | |
| if (z2 <= level <= z4) or (z4 <= level <= z2): | |
| if z2 != z4: | |
| t = (level - z2) / (z4 - z2) | |
| lat = max_lat - (r + t) * lat_res | |
| lon = min_lon + (c + 1) * lon_res | |
| if use_utm_projection: | |
| utm_x, utm_y = transformer_to_utm.transform(lon, lat) | |
| x = utm_x - center_utm_x | |
| y = utm_y - center_utm_y | |
| else: | |
| x, y = latlon_to_meters(lat, lon, center_lat, center_lon) | |
| points.append((x, y, level)) | |
| # Bottom edge (r+1, c) to (r+1, c+1) | |
| if (z3 <= level <= z4) or (z4 <= level <= z3): | |
| if z3 != z4: | |
| t = (level - z3) / (z4 - z3) | |
| lat = max_lat - (r + 1) * lat_res | |
| lon = min_lon + (c + t) * lon_res | |
| if use_utm_projection: | |
| utm_x, utm_y = transformer_to_utm.transform(lon, lat) | |
| x = utm_x - center_utm_x | |
| y = utm_y - center_utm_y | |
| else: | |
| x, y = latlon_to_meters(lat, lon, center_lat, center_lon) | |
| points.append((x, y, level)) | |
| # Left edge (r, c) to (r+1, c) | |
| if (z1 <= level <= z3) or (z3 <= level <= z1): | |
| if z1 != z3: | |
| t = (level - z1) / (z3 - z1) | |
| lat = max_lat - (r + t) * lat_res | |
| lon = min_lon + c * lon_res | |
| if use_utm_projection: | |
| utm_x, utm_y = transformer_to_utm.transform(lon, lat) | |
| x = utm_x - center_utm_x | |
| y = utm_y - center_utm_y | |
| else: | |
| x, y = latlon_to_meters(lat, lon, center_lat, center_lon) | |
| points.append((x, y, level)) | |
| # Create line segments from points (take first 2) | |
| if len(points) >= 2: | |
| for i in range(0, len(points) - 1, 2): | |
| if i + 1 < len(points): | |
| p1 = points[i] | |
| p2 = points[i + 1] | |
| # Store first point for label | |
| if level not in contour_label_points: | |
| contour_label_points[level] = p1 | |
| # Add slight offset above terrain | |
| z_offset = level + 0.5 | |
| v1 = vertex_count + 1 | |
| f.write(f"v {p1[0]:.2f} {p1[1]:.2f} {z_offset:.2f}\n") | |
| vertex_count += 1 | |
| v2 = vertex_count + 1 | |
| f.write(f"v {p2[0]:.2f} {p2[1]:.2f} {z_offset:.2f}\n") | |
| vertex_count += 1 | |
| # Line as degenerate face | |
| f.write(f"l {v1} {v2}\n") | |
| # Add elevation labels as 3D text (using simple line-based text) | |
| f.write("\ng ContourLabels\nusemtl Contour\n") | |
| for level, (x, y, z) in contour_label_points.items(): | |
| # Create simple text label using comment (visible in some viewers) | |
| # Also create a small marker point | |
| label_text = f"{int(level)}m" | |
| f.write(f"# Elevation: {label_text} at ({x:.1f}, {y:.1f})\n") | |
| # Create a small vertical line as a marker | |
| marker_height = 2.0 | |
| v1 = vertex_count + 1 | |
| f.write(f"v {x:.2f} {y:.2f} {z + 0.5:.2f}\n") | |
| vertex_count += 1 | |
| v2 = vertex_count + 1 | |
| f.write(f"v {x:.2f} {y:.2f} {z + marker_height:.2f}\n") | |
| vertex_count += 1 | |
| f.write(f"l {v1} {v2}\n") | |
| f.write("\n") | |
| # --- BUILDINGS GENERATION --- | |
| if include_buildings: | |
| for i, building in enumerate(buildings, 1): | |
| coords = [] | |
| base_z = 0.0 | |
| # Calculate base height (only if terrain is enabled) | |
| node_elevations = [] | |
| for node_id in building['nodes']: | |
| if node_id in nodes: | |
| lat, lon = nodes[node_id] | |
| if use_utm_projection: | |
| # Reproject to UTM and center around the center point | |
| utm_x, utm_y = transformer_to_utm.transform(lon, lat) | |
| x = utm_x - center_utm_x | |
| y = utm_y - center_utm_y | |
| else: | |
| x, y = latlon_to_meters(lat, lon, center_lat, center_lon) | |
| coords.append((x, y)) | |
| # Only collect elevations if terrain is enabled | |
| if include_terrain and elevation_data: | |
| node_elevations.append(get_elevation_at_point(lat, lon, elevation_data)) | |
| if not coords: | |
| continue | |
| # Set base Z to the minimum elevation of the footprint to avoid floating | |
| # Only if terrain is enabled, otherwise base_z stays at 0.0 | |
| if include_terrain and node_elevations: | |
| base_z = min(node_elevations) | |
| if len(coords) < 3: | |
| continue | |
| if coords[0] == coords[-1]: | |
| coords = coords[:-1] | |
| if len(coords) < 3: | |
| continue | |
| height = building['height'] | |
| top_z = base_z + height | |
| f.write(f"g Buildings\no Building_{i}\nusemtl White\n") | |
| # Bottom vertices | |
| bottom_start = vertex_count + 1 | |
| for x, y in coords: | |
| f.write(f"v {x:.2f} {y:.2f} {base_z:.2f}\n") | |
| vertex_count += 1 | |
| # Top vertices | |
| top_start = vertex_count + 1 | |
| for x, y in coords: | |
| f.write(f"v {x:.2f} {y:.2f} {top_z:.2f}\n") | |
| vertex_count += 1 | |
| num_points = len(coords) | |
| # Roof | |
| f.write("f") | |
| for j in range(num_points): | |
| f.write(f" {top_start + j}//2") | |
| f.write("\n") | |
| # Walls | |
| for j in range(num_points): | |
| next_j = (j + 1) % num_points | |
| v1, v2 = bottom_start + j, bottom_start + next_j | |
| v3, v4 = top_start + next_j, top_start + j | |
| # Simple normals (using 1 for side for now to simplify) | |
| f.write(f"f {v1} {v2} {v3} {v4}\n") | |
| f.write("\n") | |
| # --- ROADS GENERATION (Smooth Footprints with V2 Algorithm) --- | |
| if include_roads and roads_with_points: | |
| status += "Generating smooth road footprints...\n" | |
| # Apply UTM centering offset to road points if using UTM projection | |
| if use_utm_projection: | |
| for road in roads_with_points: | |
| road['points'] = [(x - center_utm_x, y - center_utm_y) for x, y in road['points']] | |
| # Generate smooth road geometry | |
| # Only use terrain elevation if terrain layer is enabled | |
| road_vertices, road_faces = generate_smooth_roads( | |
| roads_with_points, | |
| elevation_data, | |
| get_elevation_at_point, | |
| use_terrain=include_terrain | |
| ) | |
| if road_vertices and road_faces: | |
| f.write("g Roads\nusemtl Road\n") | |
| # Write road vertices | |
| road_vertex_start = vertex_count + 1 | |
| for x, y, z in road_vertices: | |
| f.write(f"v {x:.2f} {y:.2f} {z:.2f}\n") | |
| vertex_count += 1 | |
| # Write road faces (indices need to be offset) | |
| for i0, i1, i2 in road_faces: | |
| # road_faces uses 1-based indexing from builder, add our offset | |
| f.write(f"f {road_vertex_start + i0 - 1} {road_vertex_start + i1 - 1} {road_vertex_start + i2 - 1}\n") | |
| f.write("\n") | |
| status += f"Generated {len(road_vertices)} road vertices, {len(road_faces)} faces\n" | |
| else: | |
| status += "Warning: No road geometry generated\n" | |
| # Write MTL file | |
| with open(mtl_file, 'w') as f: | |
| f.write("# Materials\n\n") | |
| # Off-white for Buildings | |
| f.write("newmtl White\nKa 0.95 0.95 0.9\nKd 0.98 0.98 0.95\n") | |
| f.write("Ks 0.1 0.1 0.1\nNs 10.0\nd 1.0\nillum 2\n\n") | |
| # Dark gray for Roads | |
| f.write("newmtl Road\nKa 0.15 0.15 0.15\nKd 0.25 0.25 0.25\n") | |
| f.write("Ks 0.05 0.05 0.05\nNs 5.0\nd 1.0\nillum 2\n\n") | |
| # Light gray for Ground/Terrain | |
| f.write("newmtl Ground\nKa 0.7 0.7 0.7\nKd 0.75 0.75 0.75\n") | |
| f.write("Ks 0.0 0.0 0.0\nNs 0.0\nd 1.0\nillum 1\n\n") | |
| # Black for Contour lines | |
| f.write("newmtl Contour\nKa 0.0 0.0 0.0\nKd 0.05 0.05 0.05\n") | |
| f.write("Ks 0.0 0.0 0.0\nNs 0.0\nd 1.0\nillum 1\n") | |
| status += f"Generated {len(buildings)} buildings, {len(roads)} roads\n" | |
| status += f"Files: {obj_file}, {mtl_file}" | |
| return obj_file, mtl_file, status | |
| # Gradio Interface with Map and 3D Viewer | |
| with gr.Blocks(title="OSM 3D Generator", theme=gr.themes.Soft(), css=""" | |
| #map-container { height: 400px; } | |
| .model-3d { height: 500px; } | |
| #map-image .image-container { border: none !important; } | |
| #map-image .upload-container { display: none !important; } | |
| button[aria-label*="Clear"] { display: none !important; } | |
| button[aria-label*="Download"] { display: none !important; } | |
| """) as app: | |
| gr.Markdown(""" | |
| # OSM 3D Environment Generator | |
| Generate 3D building models from OpenStreetMap data | |
| """) | |
| with gr.Row(): | |
| # Left Column - Map and Controls | |
| with gr.Column(scale=1): | |
| gr.Markdown("### Location Selector") | |
| # Static map preview using Mapbox Static Images API with bounding box | |
| def get_map_url(lat, lon, square_size_km): | |
| """Generate Mapbox static image URL with bounding box overlay.""" | |
| # Calculate bounding box coordinates | |
| lat_offset, lon_offset = km_to_degrees(square_size_km / 2, lat) | |
| # Dynamic zoom level based on area size to ensure bbox fits in view | |
| if square_size_km <= 1.0: | |
| zoom = 14 | |
| elif square_size_km <= 2.0: | |
| zoom = 13 | |
| elif square_size_km <= 3.0: | |
| zoom = 12 | |
| elif square_size_km <= 4.0: | |
| zoom = 12 | |
| else: # 5.0 km | |
| zoom = 11.5 | |
| # Create GeoJSON overlay for the bounding box | |
| geojson = { | |
| "type": "FeatureCollection", | |
| "features": [{ | |
| "type": "Feature", | |
| "geometry": { | |
| "type": "Polygon", | |
| "coordinates": [[ | |
| [lon - lon_offset, lat - lat_offset], | |
| [lon + lon_offset, lat - lat_offset], | |
| [lon + lon_offset, lat + lat_offset], | |
| [lon - lon_offset, lat + lat_offset], | |
| [lon - lon_offset, lat - lat_offset] | |
| ]] | |
| }, | |
| "properties": { | |
| "stroke": "#ff0000", | |
| "stroke-width": 3, | |
| "fill": "#ff0000", | |
| "fill-opacity": 0.1 | |
| } | |
| }] | |
| } | |
| import json | |
| import urllib.parse | |
| geojson_str = json.dumps(geojson) | |
| geojson_encoded = urllib.parse.quote(geojson_str) | |
| # Use satellite-v9 for pure satellite imagery without labels | |
| return f"https://api.mapbox.com/styles/v1/mapbox/satellite-v9/static/geojson({geojson_encoded}),pin-s+ff0000({lon},{lat})/{lon},{lat},{zoom},0/600x400@2x?access_token={MAPBOX_TOKEN}" | |
| map_image = gr.Image( | |
| value=get_map_url(52.52309, 13.41657, 2.0), | |
| label="Map Preview", | |
| height=400, | |
| interactive=False, | |
| elem_id="map-image", | |
| show_label=False, | |
| show_download_button=False, | |
| show_share_button=False | |
| ) | |
| gr.Markdown(""" | |
| **Select Location:** | |
| Enter coordinates manually or use [Google Maps](https://maps.google.com) / [OpenStreetMap](https://www.openstreetmap.org) to find coordinates. | |
| Red box shows the fetch area. | |
| """) | |
| gr.Markdown("### Coordinates") | |
| with gr.Row(): | |
| lat = gr.Number(label="Latitude", value=52.52309, precision=6) | |
| lon = gr.Number(label="Longitude", value=13.41657, precision=6) | |
| update_map_btn = gr.Button("Update Map Preview", size="sm") | |
| size = gr.Slider(label="Area Size (km)", minimum=0.5, maximum=5, value=2.0, step=0.5) | |
| layers = gr.CheckboxGroup( | |
| choices=["Buildings", "Roads", "Terrain"], | |
| value=["Buildings", "Roads"], | |
| label="Layers to Generate" | |
| ) | |
| with gr.Accordion("Terrain Options", open=False): | |
| contour_interval = gr.Slider( | |
| label="Contour Line Interval (meters)", | |
| minimum=0, | |
| maximum=50, | |
| value=0, | |
| step=1, | |
| info="0 = disabled, 2-10m for detailed, 10-50m for overview. Labels show elevation." | |
| ) | |
| with gr.Accordion("Export Options", open=False): | |
| use_utm = gr.Checkbox( | |
| label="Reproject to UTM (for CAD/GIS alignment)", | |
| value=False, | |
| info="Enables coordinate system matching with UTM-projected satellite imagery" | |
| ) | |
| generate_btn = gr.Button("Generate 3D Model", variant="primary", size="lg") | |
| status_output = gr.Textbox(label="Status", lines=8, interactive=False) | |
| # Right Column - 3D Viewer and Downloads | |
| with gr.Column(scale=1): | |
| gr.Markdown("### 3D Model Viewer") | |
| model_viewer = gr.Model3D( | |
| label="3D Preview", | |
| height=500, | |
| interactive=False | |
| ) | |
| gr.Markdown("### Download Files") | |
| with gr.Row(): | |
| obj_output = gr.File(label="OBJ File", file_count="single") | |
| mtl_output = gr.File(label="MTL File", file_count="single") | |
| with gr.Row(): | |
| gr.Markdown(""" | |
| ### How to Use | |
| 1. Enter coordinates or use Google Maps/OpenStreetMap to find a location | |
| 2. Click "Update Map Preview" to visualize the selected area | |
| 3. Adjust the area size using the slider (automatically updates map) | |
| 4. Select which layers to include (Buildings, Roads, Terrain) | |
| 5. Click "Generate 3D Model" to create buildings and roads | |
| 6. Preview the model in 3D and download OBJ/MTL files | |
| ### Features | |
| - Pure satellite imagery without labels | |
| - Red bounding box shows exact fetch area | |
| - Real-time 3D preview in browser | |
| - Building heights from OSM data | |
| - **Road footprints** with realistic width (buffered polygons for CAD/laser-etch) | |
| - **High-precision Terrain** from Mapbox Terrain-RGB | |
| - Optional UTM reprojection for GIS/CAD alignment | |
| - Professional materials and normals | |
| - Compatible with Rhino, Blender, 3ds Max | |
| """) | |
| # Quick examples | |
| with gr.Row(): | |
| gr.Markdown("### Quick Examples") | |
| examples_data = [ | |
| [52.52309, 13.41657, 2.0], # Berlin | |
| [40.74882, -73.98543, 1.0], # NYC | |
| [48.85884, 2.29435, 1.5], # Paris | |
| [51.50073, -0.12463, 2.0], # London | |
| [35.67610, 139.65031, 1.5], # Tokyo | |
| ] | |
| gr.Examples( | |
| examples=examples_data, | |
| inputs=[lat, lon, size], | |
| label="Click to load example locations" | |
| ) | |
| # Event handlers | |
| def handle_generation(lat_val, lon_val, size_val, layers_val, contour_interval_val, utm_val): | |
| """Generate model and return files + preview.""" | |
| obj_file, mtl_file, status = generate_obj_file( | |
| lat_val, lon_val, size_val, layers_val, | |
| contour_interval=float(contour_interval_val), | |
| use_utm_projection=bool(utm_val) | |
| ) | |
| if obj_file: | |
| return obj_file, mtl_file, obj_file, status | |
| else: | |
| return None, None, None, status | |
| def update_map_preview(lat_val, lon_val, size_val): | |
| """Update map image when coordinates or size change.""" | |
| return get_map_url(lat_val, lon_val, size_val) | |
| # Connect button events | |
| generate_btn.click( | |
| fn=handle_generation, | |
| inputs=[lat, lon, size, layers, contour_interval, use_utm], | |
| outputs=[obj_output, mtl_output, model_viewer, status_output] | |
| ) | |
| update_map_btn.click( | |
| fn=update_map_preview, | |
| inputs=[lat, lon, size], | |
| outputs=[map_image] | |
| ) | |
| # Auto-update map when size slider changes | |
| size.change( | |
| fn=update_map_preview, | |
| inputs=[lat, lon, size], | |
| outputs=[map_image] | |
| ) | |
| if __name__ == "__main__": | |
| app.launch() | |