Source code: From DEM to 3D-printable STL with Python and NumPy
Runnable Python scripts, requirements and console output for the tutorial “From DEM to 3D-printable STL with Python and NumPy”.
Last updated
These are the exact scripts used to produce every figure and number in From DEM to 3D-printable STL with Python and NumPy. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- dem_to_stl.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython dem_to_stl.pydem_to_stl.py
"""From DEM to 3D-printable STL with Python - TechnicalGIS tutorial.
Streams a Copernicus GLO-30 DEM tile for the Edale valley (Peak District),reprojects it to British National Grid, scales it to a printable size andwrites a watertight binary STL using nothing but NumPy.Run with: python dem_to_stl.py"""from pathlib import Path
import matplotlib.pyplot as pltimport numpy as npimport rioxarray # noqa: F401 (registers the .rio accessor)from matplotlib.colors import LightSourcefrom mpl_toolkits.mplot3d.art3d import Poly3DCollectionfrom rasterio.enums import Resampling
HERE = Path(__file__).parentOUT = HERE / "output"OUT.mkdir(exist_ok=True)plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
DEM_URL = ("https://copernicus-dem-30m.s3.amazonaws.com/" "Copernicus_DSM_COG_10_N53_00_W002_00_DEM/Copernicus_DSM_COG_10_N53_00_W002_00_DEM.tif")AOI_BNG = (406_000, 380_000, 418_000, 389_000) # 12 x 9 km: Kinder plateau, Edale, Mam TorMODEL_WIDTH_MM = 150.0 # printed size of the longest sideZ_EXAGGERATION = 2.0 # vertical exaggeration makes relief readable at this scaleBASE_MM = 4.0 # solid plinth below the lowest point
def section(title: str) -> None: print(f"\n=== {title} ===")
# --- Step 1: get the DEM in a projected CRS ---------------------------------------dem = rioxarray.open_rasterio(DEM_URL, masked=True).squeeze("band", drop=True)dem = dem.rio.clip_box(-1.95, 53.30, -1.70, 53.45).load() # generous lon/lat windowdem = dem.rio.reproject("EPSG:27700", resolution=30, resampling=Resampling.bilinear)dem = dem.rio.clip_box(*AOI_BNG)z = dem.values.astype("float64")section("DEM")print("shape (rows, cols):", z.shape, "| resolution:", dem.rio.resolution())print("NaNs:", int(np.isnan(z).sum()))print(f"elevation range: {np.nanmin(z):.1f} - {np.nanmax(z):.1f} m")
# --- Step 2: scale real-world metres to millimetres -------------------------------rows, cols = z.shapecell_m = abs(dem.rio.resolution()[0])width_m, height_m = (cols - 1) * cell_m, (rows - 1) * cell_mscale = MODEL_WIDTH_MM / max(width_m, height_m) # mm per metresection("model scale")print(f"ground extent: {width_m / 1000:.2f} x {height_m / 1000:.2f} km")print(f"horizontal scale: 1:{1 / (scale / 1000):,.0f}")print(f"cell size on the print: {cell_m * scale:.3f} mm")
x = np.arange(cols) * cell_m * scaley = (rows - 1 - np.arange(rows)) * cell_m * scale # row 0 is north -> largest yX, Y = np.meshgrid(x, y)Z = BASE_MM + (z - np.nanmin(z)) * scale * Z_EXAGGERATIONprint(f"model size: {x.max():.1f} x {y.max():.1f} x {Z.max():.1f} mm (W x D x H)")
# --- Step 3: triangulate a closed (watertight) solid -------------------------------def grid_triangles(r: int, c: int) -> np.ndarray: """Vertex indices for two counter-clockwise triangles per grid cell (viewed from above).""" idx = np.arange(r * c).reshape(r, c) a, b = idx[:-1, :-1].ravel(), idx[:-1, 1:].ravel() # top-left, top-right d, e = idx[1:, :-1].ravel(), idx[1:, 1:].ravel() # bottom-left, bottom-right # y decreases with row, so (a, d, b) and (b, d, e) are anticlockwise from above return np.concatenate([np.column_stack([a, d, b]), np.column_stack([b, d, e])])
top_v = np.column_stack([X.ravel(), Y.ravel(), Z.ravel()])bot_v = top_v.copy()bot_v[:, 2] = 0.0verts = np.vstack([top_v, bot_v])n = rows * colstri_top = grid_triangles(rows, cols)tri_bottom = tri_top[:, ::-1] + n # reversed winding -> faces down
# Walls: walk the outline clockwise and join each top edge to the bottom.idx = np.arange(n).reshape(rows, cols)ring = np.concatenate([idx[0, :], idx[1:, -1], idx[-1, -2::-1], idx[-2:0:-1, 0]])p, q = ring, np.roll(ring, -1)walls = np.concatenate([np.column_stack([p, q, p + n]), np.column_stack([q, q + n, p + n])])triangles = np.vstack([tri_top, tri_bottom, walls])section("mesh")print(f"vertices: {len(verts):,} triangles: {len(triangles):,} " f"(surface {len(tri_top):,}, base {len(tri_bottom):,}, walls {len(walls):,})")
# Watertight check: every undirected edge must be shared by exactly two triangles,# and every directed edge must appear exactly once (consistent orientation).edges = np.concatenate([triangles[:, [0, 1]], triangles[:, [1, 2]], triangles[:, [2, 0]]])_, und_counts = np.unique(np.sort(edges, axis=1), axis=0, return_counts=True)_, dir_counts = np.unique(edges, axis=0, return_counts=True)print("watertight:", bool((und_counts == 2).all()), "| consistently oriented:", bool((dir_counts == 1).all()))
tri_xyz = verts[triangles] # (n_tri, 3 vertices, xyz)normals = np.cross(tri_xyz[:, 1] - tri_xyz[:, 0], tri_xyz[:, 2] - tri_xyz[:, 0])volume_mm3 = np.einsum("ij,ij->i", tri_xyz[:, 0], np.cross(tri_xyz[:, 1], tri_xyz[:, 2])).sum() / 6print(f"enclosed volume: {volume_mm3 / 1000:.1f} cm³ (positive = outward-facing normals)")
# --- Step 4: write a binary STL with a NumPy structured array -----------------------lengths = np.linalg.norm(normals, axis=1, keepdims=True)unit = np.divide(normals, lengths, out=np.zeros_like(normals), where=lengths > 0)record = np.dtype([("normal", "<f4", 3), ("v", "<f4", (3, 3)), ("attr", "<u2")])data = np.zeros(len(triangles), dtype=record)data["normal"], data["v"] = unit, tri_xyzstl_path = OUT / "edale_kinder_mam_tor.stl"with open(stl_path, "wb") as f: f.write(b"TechnicalGIS Edale DEM - Copernicus GLO-30".ljust(80, b" ")) f.write(np.uint32(len(data)).tobytes()) f.write(data.tobytes())section("STL")print(f"wrote {stl_path.name}: {stl_path.stat().st_size / 1e6:.1f} MB, {len(data):,} facets")
# Read it back to prove the file is valid (and to draw the preview from the file itself).raw = np.fromfile(stl_path, dtype=np.uint8)count = int(np.frombuffer(raw[80:84].tobytes(), "<u4")[0])back = np.frombuffer(raw[84:].tobytes(), dtype=record)assert count == len(back) == len(triangles)print("read back:", count, "facets | bounding box (mm):", np.round(back["v"].reshape(-1, 3).min(0), 1), "->", np.round(back["v"].reshape(-1, 3).max(0), 1))
# --- Step 5: previews ------------------------------------------------------------------ls = LightSource(azdeg=315, altdeg=45)fig, ax = plt.subplots(figsize=(7.5, 5.8))rgb = ls.shade(z, cmap=plt.cm.gist_earth, blend_mode="soft", vert_exag=2, dx=cell_m, dy=cell_m, vmin=np.nanmin(z) - 150, vmax=np.nanmax(z) + 50)ax.imshow(rgb, extent=AOI_BNG[0::2] + AOI_BNG[1::2])labels = {"Mam Tor": (412_750, 383_640), "Kinder Scout": (408_600, 387_600), "Edale": (412_300, 385_700), "Lose Hill": (415_300, 385_300)}for name, (lx, ly) in labels.items(): ax.plot(lx, ly, "^", color="black", ms=5) ax.annotate(name, (lx, ly), xytext=(5, 5), textcoords="offset points", fontsize=8, bbox=dict(boxstyle="round,pad=0.2", fc="white", alpha=0.75, lw=0))ax.set_title("Copernicus GLO-30 DEM, Edale valley (British National Grid, 30 m)")ax.ticklabel_format(style="plain")fig.savefig(OUT / "01-dem-aoi.png")plt.close(fig)
# Render a decimated copy of the surface (every 3rd vertex) - plotting ~100k facets# in matplotlib is slow, and the full-resolution file has already been validated above.step = 3sub = grid_triangles(len(range(0, rows, step)), len(range(0, cols, step)))Xs, Ys, Zs = X[::step, ::step], Y[::step, ::step], Z[::step, ::step]pts = np.column_stack([Xs.ravel(), Ys.ravel(), Zs.ravel()])facet_xyz = pts[sub]facet_n = np.cross(facet_xyz[:, 1] - facet_xyz[:, 0], facet_xyz[:, 2] - facet_xyz[:, 0])fig = plt.figure(figsize=(9, 6.5))ax = fig.add_subplot(projection="3d")height = (facet_xyz[:, :, 2].mean(1) - BASE_MM) / (Z.max() - BASE_MM)colours = plt.cm.gist_earth(0.25 + 0.6 * height)colours[:, :3] *= (0.45 + 0.55 * ls.shade_normals(facet_n, fraction=1.0))[:, None]ax.add_collection3d(Poly3DCollection(facet_xyz, linewidths=0, facecolors=colours))for wall in (np.column_stack([x, np.full(cols, y.max()), Z[0]]), np.column_stack([x, np.zeros(cols), Z[-1]])): ax.add_collection3d(Poly3DCollection([np.vstack([wall, [[wall[-1, 0], wall[-1, 1], 0], [wall[0, 0], wall[0, 1], 0]]])], facecolors="#c9b79c", linewidths=0))ax.set_xlim(0, x.max())ax.set_ylim(0, y.max())ax.set_zlim(0, Z.max())ax.set_box_aspect((x.max(), y.max(), Z.max())) # true printed proportionsax.set_zticks([0, BASE_MM, round(Z.max())])for axis in (ax.xaxis, ax.yaxis, ax.zaxis): axis.pane.set_visible(False)ax.view_init(elev=28, azim=-60)ax.set_xlabel("mm")ax.set_ylabel("mm")ax.set_zlabel("mm")ax.set_title(f"STL preview: {x.max():.0f} x {y.max():.0f} mm, {Z_EXAGGERATION}x vertical exaggeration")fig.savefig(OUT / "02-stl-preview.png")plt.close(fig)
# Cross-section through Mam Tor showing the plinth and exaggeration.r = int(round((AOI_BNG[3] - 383_640) / cell_m))fig, ax = plt.subplots(figsize=(8, 2.6))ax.fill_between(x, 0, Z[r], color="#c9b79c")ax.plot(x, Z[r], color="#7f5539", lw=1)ax.axhline(BASE_MM, color="#495057", ls="--", lw=0.8)ax.text(2, BASE_MM + 0.6, f"{BASE_MM:.0f} mm plinth", fontsize=8)ax.set_aspect("equal")ax.set_xlabel("mm")ax.set_ylabel("mm")ax.set_title("Model cross-section (west → east) through Mam Tor")fig.savefig(OUT / "03-cross-section.png")plt.close(fig)print("\nfigures written to", OUT)