initial commit
This commit is contained in:
commit
316cbe065a
3 changed files with 285 additions and 0 deletions
16
Dockerfile
Normal file
16
Dockerfile
Normal 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
5
requirements.txt
Normal 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
264
src/__init__.py
Normal 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)
|
Loading…
Add table
Reference in a new issue