Export OpenStreetMap (OSM) features to STL for 3D printing.
Now supports streets (buffered & extruded roadbeds) in addition to buildings.
Support the project by grabbing a 3D print from Etsy.com!
highway=residential, primary, …).UNIFORM_WIDTH_MM (or per-type map) – model-space road widthroad_height_mm – relief above the base (e.g., 1.4–1.8 mm)EPS – tiny Z-offset (e.g., 0.02 mm) to prevent coplanar mergingTip: At ~2 km mapped to ~180–200 mm, 1 mm ≈ 9–11 m. Start with 0.6–1.0 mm for residential roads.
We recommend OSMnx v2 and recent geospatial libs.
# (optional) new virtual environment
# python -m venv .venv && . .venv/Scripts/activate # Windows PowerShell
# source .venv/bin/activate # macOS/Linux
pip install "osmnx>=2.0.2" "shapely>=2.0.4" "geopandas>=0.14" "pyproj>=3.6" "networkx>=3.2" "numpy-stl"
On Windows, using Conda can be even smoother:
conda create -n osm2stl -c conda-forge python=3.11 osmnx shapely geopandas pyproj networkx numpy-stl -y
This repo uses the internal convention:
# our code: (north, east, south, west)
bbox = (N, E, S, W)
OSMnx v2 calls that as bbox=(west, south, east, north) when fetching features/graphs, and the code already converts for you.
Center City Philadelphia (Broad & Market as center, ~2 km square):
bbox = (39.9615, -75.1520, 39.9435, -75.1750) # (north, east, south, west)
python main.py
This produces e.g. city_with_roads_and_buildings.stl.
main.py)target_size (mm): width of the modeled area on your print bed (the base is slightly larger to frame).max_height_mm: tallest building height in mm after scaling.default_height & building:levels:['height', 'building:height', 'building:levels'] (levels × ~3 m) if explicit heights are missing.Inside scale_coordinates(...) the roads block:
EPS Z-offset so slicers don’t merge roads into the base.[roads-per-edge] pieces = 1234, summed_coverage = 0.064
Aim for ~0.04–0.10 coverage at city scale; if you see ~0.20, your roads are too wide (they’ll look like a second base slab).Tuning:
UNIFORM_WIDTH_MM = 0.8 and road_height_mm = 1.6.In main():
# bbox = (north, east, south, west)
bbox = (39.9615, -75.1520, 39.9435, -75.1750)
buildings = fetch_building_data(bbox) # OSMnx v2: features_from_bbox under the hood
roads = fetch_road_data(bbox, 'drive') # OSMnx v2: graph_from_bbox(bbox=(W,S,E,N))
vertices, faces = scale_coordinates(
gdf_buildings=buildings,
bbox=bbox,
target_size=180,
max_height_mm=40,
default_height=40,
base_thickness=1.8,
roads_gdf=roads,
road_height_mm=1.6
)
save_to_stl(vertices, faces, 'city_with_roads_and_buildings.stl')
The roads section (already in the file) is configured for per-edge extrusion and prints a coverage diagnostic.
unary_union).EPS = 0.02 (or 0.03) so roads start slightly above the base.road_height_mm ≥ 1.4 so the terrace is visible across a few layers.summed_coverage > 0.15 → widths too large; reduce to 0.6–0.8 mm.summed_coverage < 0.02 → widths too small; raise to 1.0–1.2 mm.That’s fan-triangulation on concave polygons. The script now uses Shapely triangulation for roads to prevent this.
You’re likely mixing OSMnx v1 and v2 docs. This repo assumes OSMnx ≥ 2.0.
features.features_from_bbox(bbox=(W, S, E, N), tags=...).graph_from_bbox(bbox=(W, S, E, N), network_type='drive', simplify=True).Once the geometry looks good, switch to per-type widths (still per-edge, no union):
USE_PER_TYPE = True
width_map = {
"motorway": 3.2, "trunk": 2.8, "primary": 2.6,
"secondary": 2.2, "tertiary": 1.8, "residential": 1.2,
"service": 1.0, "living_street": 1.0, "unclassified": 1.2,
"cycleway": 0.9, "footway": 0.8, "path": 0.8
}
# clamp to [0.6, 3.2] in model mm; 1 mm ~ 9–11 m at this scale
Happy printing! 🗺️🧱🛣️