initial commit

This commit is contained in:
Brent Schroeter 2025-05-17 00:14:02 -07:00
commit 316cbe065a
3 changed files with 285 additions and 0 deletions

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM python:3.13.3-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y openscad libgdal-dev
RUN python3 -m pip install setuptools wheel markupsafe numpy
COPY requirements.txt .
RUN python3 -m pip install -r requirements.txt
COPY src src
VOLUME /app/data
ENTRYPOINT ["python3", "src/__init__.py"]

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
geopandas==1.0.1
gdal[numpy]==3.6.2
pyrosm==0.6.2
shapely==2.1.0
solidpython2==2.1.1

264
src/__init__.py Normal file
View file

@ -0,0 +1,264 @@
import argparse
from math import asin, cos, pi, sin, sqrt
import numpy as np
import solid2
from osgeo import gdal
from pyrosm import OSM
from shapely import affinity, Polygon, union_all
def haversine_dist(lat1, lng1, lat2, lng2):
"""
Calculate distance between two globe coordinates in meters.
"""
EARTH_RADIUS = 6378137 # in meters
lat1_rads = lat1 * pi / 180
lat2_rads = lat2 * pi / 180
lng1_rads = lng1 * pi / 180
lng2_rads = lng2 * pi / 180
d_lat = lat2_rads - lat1_rads
d_lng = lng2_rads - lng1_rads
hav_theta = sin(d_lat / 2)**2 + cos(lat1_rads) * cos(lat2_rads) * sin(d_lng / 2)**2
return 2 * EARTH_RADIUS * asin(sqrt(hav_theta))
def to_scad_polygon(shapely_polygon):
"""
Generate a solid2 SCAD polygon object from a shapely Polygon.
"""
points = list(shapely_polygon.exterior.coords)
paths = [[i for i, _ in enumerate(shapely_polygon.exterior.coords)]]
for interior in shapely_polygon.interiors:
paths.append([len(points) + i for i in range(len(interior.coords))])
points += list(interior.coords)
return solid2.polygon(points, paths)
def get_building_height(building):
"""
Infer the height, in meters, of a building from OSM building data.
"""
DEFAULT_METERS_PER_LEVEL = 5
DEFAULT_LEVELS_PER_BUILDING = 3
if building["height"] is not None:
return float(building["height"])
if building["building:levels"] is not None:
return float(building["building:levels"]) * DEFAULT_METERS_PER_LEVEL
return DEFAULT_METERS_PER_LEVEL * DEFAULT_LEVELS_PER_BUILDING
class ElevationMap:
def __init__(self, tiff_file_path):
self._gdal = gdal.Open(tiff_file_path)
self._transform = self._gdal.GetGeoTransform()
# Gets band at index 1 from the data
self._band = self._gdal.GetRasterBand(1)
def get_elevation(self, lat, lng):
"""
Get elevation, in meters above sea level, for a point within the loaded
dataset.
"""
x = int((lng - self._transform[0]) / self._transform[1])
y = int((lat - self._transform[3]) / self._transform[5])
if 0 <= x < self._gdal.RasterXSize and 0 <= y < self._gdal.RasterYSize:
return self._band.ReadAsArray(x, y, 1, 1)[0, 0]
raise IndexError("coordinates are outside of elevation map area")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--osm",
help=".osm.pbf file",
required=True,
)
parser.add_argument(
"--topo",
help=".tif file from https://portal.opentopography.org/raster?opentopoID=OTSRTM.082015.4326.1",
required=True,
)
parser.add_argument(
"--bbox",
help="<min lon>,<min lat>,<max lon>,<max lat>",
required=True,
)
parser.add_argument(
"--edge-len",
type=float,
default=100.0,
help="Long edge length of output STL bounding box",
)
parser.add_argument(
"--output",
help="File path for STL output",
required=True,
)
parser.add_argument(
"--baseplate-h",
type=float,
default=5.0,
help="Depth of the base plate in millimeters",
)
parser.add_argument(
"--circular",
action="store_true",
)
args = parser.parse_args()
elev_map = ElevationMap(args.topo)
osm = OSM(args.osm)
solid2.set_global_fn(48)
drive_net = osm.get_network(network_type="driving")
natural = osm.get_natural()
buildings = osm.get_buildings()
map_bounds = [float(coord) for coord in args.bbox.split(",")]
map_width = map_bounds[2] - map_bounds[0]
map_height = map_bounds[3] - map_bounds[1]
# Figure out how much to squish the map's coordinate system to make it
# square
meters_per_unit_lat = haversine_dist(
map_bounds[1],
map_bounds[0],
map_bounds[3],
map_bounds[0],
) / map_height
meters_per_unit_lng = haversine_dist(
map_bounds[1],
map_bounds[0],
map_bounds[1],
map_bounds[2],
) / map_width
lat_lng_ratio = meters_per_unit_lat / meters_per_unit_lng
map_width_meters = map_width * meters_per_unit_lng
map_height_meters = map_height * meters_per_unit_lat
aspect_ratio = map_width_meters / map_height_meters
if aspect_ratio > 1:
# Wide
model_bounds = (0, 0, args.edge_len, args.edge_len / aspect_ratio)
else:
# Tall
model_bounds = (0, 0, args.edge_len * aspect_ratio, args.edge_len)
model_rect = Polygon([
(0, 0),
(0, model_bounds[3]),
(model_bounds[2], model_bounds[3]),
(model_bounds[2], 0),
])
# Scale factor from real-world meters to the model coordinate system
scale_factor = model_bounds[2] / map_width_meters
print("Map bounds:", map_bounds)
print("Meters per degree latitude:", meters_per_unit_lat)
print("Meters per degree longitude:", meters_per_unit_lng)
print("Aspect ratio:", aspect_ratio)
print("Output bounds:", model_bounds)
print(
"Scale factor (millimeter in model per meter in world):",
f"{scale_factor:.4g}",
)
def convert_shape_from_world_coords(shape):
# Move the coordinate system so that the origin is at the bottom left
# of the map rectangle, then scale the shape in x and y directions
return affinity.scale(
affinity.translate(shape, -map_bounds[0], -map_bounds[1]),
model_bounds[2] / map_width,
model_bounds[3] / map_height,
origin=(0, 0),
)
model = solid2.cube([model_bounds[2] - model_bounds[0], model_bounds[3] - model_bounds[1], args.baseplate_h])
print("Calculating elevations...")
# Slice the model area into a rectilinear grid to approximate topography
ELEV_RES = 30
elev_grid = np.zeros((ELEV_RES, ELEV_RES))
for i in range(ELEV_RES):
for j in range(ELEV_RES):
elev_grid[i, j] = elev_map.get_elevation(
map_bounds[1] + (map_bounds[3] - map_bounds[1]) / ELEV_RES * j,
map_bounds[0] + (map_bounds[2] - map_bounds[0]) / ELEV_RES * i,
)
min_world_elev = elev_grid.min()
elev_grid = (elev_grid - min_world_elev) * scale_factor
ROAD_HEIGHT = 0.25
# Render roads on slopes by over-extruding them and then calculating their
# intersection with a slightly offset elevation grid
drive_mask = None
for i in range(ELEV_RES):
for j in range(ELEV_RES):
if elev_grid[i, j] > 0:
model += solid2.cube([
model_bounds[2] / ELEV_RES,
model_bounds[3] / ELEV_RES,
elev_grid[i, j] + args.baseplate_h,
]).translate(
model_bounds[2] / ELEV_RES * i,
model_bounds[3] / ELEV_RES * j,
0,
)
drive_mask_cube = solid2.cube([
model_bounds[2] / ELEV_RES,
model_bounds[3] / ELEV_RES,
elev_grid[i, j] + args.baseplate_h + ROAD_HEIGHT,
]).translate(
model_bounds[2] / ELEV_RES * i,
model_bounds[3] / ELEV_RES * j,
0,
)
if drive_mask is None:
drive_mask = drive_mask_cube
else:
drive_mask += drive_mask_cube
print("Constructing networks...")
drive_polygons = []
for shape in drive_net["geometry"]:
shape = convert_shape_from_world_coords(shape).simplify(1).buffer(0.25)
drive_polygons.append(shape.intersection(model_rect))
drive_polygons = [shape for shape in drive_polygons if not shape.is_empty]
for shape in union_all(drive_polygons).geoms:
model += solid2.linear_extrude(100)(to_scad_polygon(shape)).translate(0, 0, args.baseplate_h) * drive_mask
print("Constructing buildings...")
for i, building in buildings.iterrows():
shape = convert_shape_from_world_coords(building["geometry"])
shape = shape.buffer(0.125)
# Simplifying building shapes after scaling can speed up rendering by
# an order of magnitude
shape = shape.simplify(0.05)
shape = shape.intersection(model_rect)
if not shape.is_empty:
height = get_building_height(building) * scale_factor
building_center = building["geometry"].centroid
# Computing elevation from the elevation map can result in weird
# artifacts, so calculate based on the less granular precomputed
# grid instead
elev = elev_grid[
min(max(int((building_center.x - map_bounds[0]) / map_width * ELEV_RES), 0), ELEV_RES - 1),
min(max(int((building_center.y - map_bounds[1]) / map_height * ELEV_RES), 0), ELEV_RES - 1),
]
if shape.geom_type == "Polygon":
model += solid2.linear_extrude(args.baseplate_h + elev + ROAD_HEIGHT + height)(to_scad_polygon(shape))
else:
for shape in shape.geoms:
model += solid2.linear_extrude(args.baseplate_h + elev + ROAD_HEIGHT + height)(to_scad_polygon(shape))
if args.circular:
radius = min(model_bounds[2], model_bounds[3]) / 2
model *= solid2.translate(model_bounds[2] / 2, model_bounds[3] / 2, 0)(
solid2.cylinder(100, r=radius),
)
print("Rendering... (grab a cup of coffee)")
solid2.render_to_stl_file(model, args.output)