"""A 3D-printable city model: OpenStreetMap building footprints on Environment Agency LIDAR terrain. Fetches the 1 m LIDAR DTM and first-return DSM for central Durham, takes building footprints from OpenStreetMap, removes detail too thin to print, and builds two versions of the city as watertight solids with manifold3d: flat-roofed blocks, one LIDAR height per building (90th percentile of the DSM inside its footprint), and buildings whose roofs follow the DSM surface. It also writes the shaped buildings as a separate part, and a 3MF with terrain and buildings, for a two-colour print. Every mesh is checked for watertightness and orientation before it is written. TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/3d-printed-city-model-osm-lidar/ Data: Environment Agency LIDAR Composite DTM and First Return DSM 1 m via the Defra WCS (Open Government Licence v3); building footprints from OpenStreetMap (© OpenStreetMap contributors, ODbL). """ import json import sys import time import zipfile import xml.etree.ElementTree as ET from importlib.metadata import version from pathlib import Path import numpy as np import requests import rasterio import shapely from rasterio import features from rasterio.transform import from_origin from pyproj import Transformer from shapely import affinity from shapely.geometry import LineString, MultiPolygon, Polygon, box from shapely.geometry.polygon import orient from shapely.ops import linemerge, polygonize, unary_union import manifold3d as mf import pyvista as pv import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.collections import PolyCollection from matplotlib.colors import LightSource, Normalize plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9, "figure.facecolor": "white", "axes.facecolor": "white"}) pv.OFF_SCREEN = True DATA = Path("data"); DATA.mkdir(exist_ok=True) OUT = Path("output"); OUT.mkdir(exist_ok=True) UA = {"User-Agent": "TechnicalGIS-tutorial (https://www.technicalgis.com)"} AOI = (426_900, 541_700, 428_100, 542_900) # 1.2 x 1.2 km of central Durham, British National Grid TILE = 600 # metres per WCS request (600 x 600 cells of 1 m) RES = 2.0 # metres per cell of the printed terrain WCS = "https://environment.data.gov.uk/spatialdata/{}/wcs?service=WCS&version=2.0.1&request=GetCoverage" \ "&format=image/tiff&CoverageId={}" DTM = WCS.format("lidar-composite-digital-terrain-model-dtm-1m", "13787b9a-26a4-4775-8523-806d13af58fc__Lidar_Composite_Elevation_DTM_1m") DSM = WCS.format("lidar-composite-digital-surface-model-first-return-dsm-1m", "df4e3ec3-315e-48aa-aaaf-b5ae74d7b2bb__Lidar_Composite_Elevation_FZ_DSM_1m") OVERPASS = ["https://overpass-api.de/api/interpreter", "https://overpass.private.coffee/api/interpreter", "https://overpass.kumi.systems/api/interpreter"] MODEL_MM = 160.0 # printed width of the square model Z_EXAGGERATION = 1.0 # true scale: buildings and hills are not stretched BASE_MM = 3.0 MIN_FEATURE_MM = 0.8 # two 0.4 mm extrusion lines: narrower parts of a block are removed MIN_AREA_MM2 = 0.8 # smaller printable pieces are dropped MIN_HEIGHT_M = 2.0 # below this, the LIDAR has no roof for the building FALLBACK_M = 3.0 # roof height used for those buildings: one storey SIMPLIFY_MM = 0.05 SAND, GREY = "#e8dcc0", "#9aa3ad" def section(title): print(f"\n=== {title} ===") def get(url, path, tries=6): """Download once and cache. The Defra WCS rate-limits bursts (HTTP 429), so back off and retry.""" if not path.exists(): for attempt in range(tries): try: r = requests.get(url, timeout=300, headers=UA) r.raise_for_status() if not r.headers.get("Content-Type", "").startswith("image/"): raise requests.RequestException(f"not an image: {r.text[:200]}") break except requests.RequestException as err: if attempt == tries - 1: raise wait = getattr(err.response, "headers", {}).get("Retry-After") time.sleep(float(wait) if wait and wait.isdigit() else 20 * (attempt + 1)) path.write_bytes(r.content) time.sleep(1) return path def mosaic(url, name): """Fetch the AOI in TILE x TILE m requests at 1 m and place them in one array (north up).""" X0, Y0, X1, Y1 = AOI out = np.full((Y1 - Y0, X1 - X0), np.nan) for x in range(X0, X1, TILE): for y in range(Y0, Y1, TILE): f = get(f"{url}&subset=E({x},{x + TILE})&subset=N({y},{y + TILE})", DATA / f"{name}_{x}_{y}_{TILE}m.tif") with rasterio.open(f) as src: a = src.read(1).astype("float64") a[a < -1000] = np.nan r, c = int(round(Y1 - src.bounds.top)), int(round(src.bounds.left - X0)) out[r:r + a.shape[0], c:c + a.shape[1]] = a return out # ------------------------------------------------------------------ mesh helpers (as in parts 1 to 3) def grid_triangles(r, c): idx = np.arange(r * c).reshape(r, c) a, b = idx[:-1, :-1].ravel(), idx[:-1, 1:].ravel() d, e = idx[1:, :-1].ravel(), idx[1:, 1:].ravel() return np.concatenate([np.column_stack([a, d, b]), np.column_stack([b, d, e])]) def heightfield_solid(Z, cell_mm): """Closed solid: the surface Z (mm), a flat base at z = 0 and four side walls.""" rows, cols = Z.shape X, Y = np.meshgrid(np.arange(cols) * cell_mm, (rows - 1 - np.arange(rows)) * cell_mm) top = np.column_stack([X.ravel(), Y.ravel(), Z.ravel()]) bot = top.copy() bot[:, 2] = 0.0 n = rows * cols tri_top = grid_triangles(rows, cols) 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])]) return np.vstack([top, bot]), np.vstack([tri_top, tri_top[:, ::-1] + n, walls]) def to_manifold(verts, tris): m = mf.Manifold(mf.Mesh(vert_properties=np.ascontiguousarray(verts, dtype=np.float32), tri_verts=np.ascontiguousarray(tris, dtype=np.uint32))) assert m.status() == mf.Error.NoError, m.status() return m def from_manifold(m): mesh = m.to_mesh() return np.asarray(mesh.vert_properties)[:, :3].astype("float64"), np.asarray(mesh.tri_verts).astype("int64") def check_mesh(name, verts, tris): """Watertight: every undirected edge used twice. Oriented: every directed edge used once.""" e = np.concatenate([tris[:, [0, 1]], tris[:, [1, 2]], tris[:, [2, 0]]]) n = len(verts) und = np.unique(np.sort(e, axis=1) @ np.array([n, 1]), return_counts=True)[1] drc = np.unique(e @ np.array([n, 1]), return_counts=True)[1] t = verts[tris] vol = np.einsum("ij,ij->i", t[:, 0], np.cross(t[:, 1], t[:, 2])).sum() / 6 ok = bool((und == 2).all() and (drc == 1).all() and vol > 0) print(f"{name:<10s} {len(tris):>9,} triangles | watertight {bool((und == 2).all())} | " f"oriented {bool((drc == 1).all())} | volume {vol / 1000:7.2f} cm3") return ok, vol def write_stl(path, verts, tris, header): t = verts[tris] nrm = np.cross(t[:, 1] - t[:, 0], t[:, 2] - t[:, 0]) ln = np.linalg.norm(nrm, axis=1, keepdims=True) rec = np.zeros(len(tris), dtype=[("normal", " 0) rec["v"] = t with open(path, "wb") as f: f.write(header.encode("ascii")[:80].ljust(80, b" ")) f.write(np.uint32(len(rec)).tobytes()) f.write(rec.tobytes()) def write_3mf(path, parts): """Minimal 3MF: one mesh object per part, each with its own display colour.""" ns = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xml = [f'\n', "", ''] xml += [f'' for name, _, _, colour in parts] xml.append("") for i, (name, v, t, _) in enumerate(parts): xml.append(f'') xml.append("".join(f'' for x, y, z in v)) xml.append("") xml.append("".join(f'' for a, b, c in t)) xml.append("") xml += ["", ""] + [f'' for i in range(len(parts))] xml += ["", ""] with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: z.writestr("[Content_Types].xml", '') z.writestr("_rels/.rels", '') z.writestr("3D/3dmodel.model", "\n".join(xml)) def polygons(g): return list(g.geoms) if isinstance(g, MultiPolygon) else [g] if isinstance(g, Polygon) and not g.is_empty else \ [p for p in getattr(g, "geoms", []) if isinstance(p, Polygon)] section("versions") print(f"python {sys.version.split()[0]} | numpy {np.__version__} | rasterio {rasterio.__version__} | " f"shapely {version('shapely')} | manifold3d {version('manifold3d')} | pyvista {pv.__version__}") X0, Y0, X1, Y1 = AOI # ------------------------------------------------------------------ Step 1: LIDAR terrain and surface section("Step 1: Environment Agency LIDAR DTM and DSM at 1 m") dtm = mosaic(DTM, "ea_dtm") dsm = mosaic(DSM, "ea_dsm") print(f"DTM {dtm.shape[1]} x {dtm.shape[0]} cells | no-data {np.isnan(dtm).sum():,} | " f"{np.nanmin(dtm):.1f} - {np.nanmax(dtm):.1f} m") print(f"DSM {dsm.shape[1]} x {dsm.shape[0]} cells | no-data {np.isnan(dsm).sum():,} | " f"{np.nanmin(dsm):.1f} - {np.nanmax(dsm):.1f} m") ndsm = dsm - dtm # height above the ground: buildings, trees, bridges print(f"DSM - DTM: median {np.nanmedian(ndsm):.2f} m | 99th percentile {np.nanpercentile(ndsm, 99):.1f} m | " f"below -0.5 m: {(ndsm < -0.5).mean() * 100:.2f} % of cells") # ------------------------------------------------------------------ Step 2: OpenStreetMap buildings section("Step 2: OpenStreetMap building footprints") to_wgs = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True) lon, lat = to_wgs.transform([X0, X1, X0, X1], [Y0, Y0, Y1, Y1]) bbox = f"{min(lat)},{min(lon)},{max(lat)},{max(lon)}" cache = DATA / "osm_buildings.json" if not cache.exists(): query = f'[out:json][timeout:120];(way["building"]({bbox});relation["building"]({bbox}););out geom;' for attempt in range(12): # public Overpass servers are busy: rotate and back off endpoint = OVERPASS[attempt % len(OVERPASS)] try: r = requests.post(endpoint, data={"data": query}, headers=UA, timeout=200) r.raise_for_status() json.loads(r.text)["elements"] cache.write_text(r.text, encoding="utf-8") break except (requests.RequestException, ValueError, KeyError) as err: print(f"{endpoint}: {str(err)[:80]} - retrying") time.sleep(10 * (attempt // len(OVERPASS) + 1)) osm = json.loads(cache.read_text(encoding="utf-8"))["elements"] to_bng = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True).transform def ring(geom): return [(p["lon"], p["lat"]) for p in geom] buildings = [] for e in osm: if e["type"] == "way" and len(e.get("geometry", [])) >= 4 and e["geometry"][0] == e["geometry"][-1]: g = Polygon(ring(e["geometry"])) elif e["type"] == "relation": parts = {r: [LineString(ring(m["geometry"])) for m in e["members"] if m["type"] == "way" and m.get("role") == r and len(m.get("geometry", [])) >= 2] for r in ("outer", "inner")} outer = unary_union(list(polygonize(linemerge(parts["outer"])))) if parts["outer"] else Polygon() inner = unary_union(list(polygonize(linemerge(parts["inner"])))) if parts["inner"] else Polygon() g = outer.difference(inner) else: continue g = shapely.transform(g, lambda xy: np.column_stack(to_bng(xy[:, 0], xy[:, 1]))).buffer(0) if not g.is_empty: buildings.append({"id": f"{e['type']}/{e['id']}", "geom": g, "tags": e.get("tags", {})}) inside = box(X0 + 1, Y0 + 1, X1 - 1, Y1 - 1) for b in buildings: b["geom"] = b["geom"].intersection(inside) buildings = [b for b in buildings if b["geom"].area > 0] print(f"Overpass: {len(osm):,} elements -> {len(buildings):,} footprints inside the model area " f"({sum(b['id'].startswith('relation') for b in buildings)} multipolygons)") print(f"tagged: height {sum('height' in b['tags'] for b in buildings)} | " f"building:levels {sum('building:levels' in b['tags'] for b in buildings)}") print(f"footprint area: total {sum(b['geom'].area for b in buildings) / 1e4:.1f} ha | " f"median {np.median([b['geom'].area for b in buildings]):.0f} m2") # ------------------------------------------------------------------ Step 3: roof heights from LIDAR section("Step 3: a roof height for every building from the LIDAR") labels = features.rasterize([(b["geom"], i + 1) for i, b in enumerate(buildings)], out_shape=dtm.shape, transform=from_origin(X0, Y1, 1.0, 1.0), fill=0, dtype="int32") lab = labels.ravel() order = np.argsort(lab, kind="stable") starts = np.searchsorted(lab[order], np.arange(1, len(buildings) + 2)) n_fallback = n_nocell = 0 for i, b in enumerate(buildings): cells = order[starts[i]:starts[i + 1]] surface, ground = dsm.ravel()[cells], dtm.ravel()[cells] ok = ~np.isnan(surface) & ~np.isnan(ground) if ok.sum() == 0: c = b["geom"].representative_point() r, k = int(Y1 - c.y), int(c.x - X0) b["ground"], b["height"], b["source"] = dtm[r, k], FALLBACK_M, "no cell" n_nocell += 1 continue b["ground"] = float(np.median(ground[ok])) h = float(np.percentile(surface[ok], 90)) - b["ground"] # 90th percentile: the roof, not the eaves if h < MIN_HEIGHT_M: h, b["source"] = FALLBACK_M, "fallback" n_fallback += 1 else: b["source"] = "lidar" b["height"] = h hts = np.array([b["height"] for b in buildings if b["source"] == "lidar"]) print(f"LIDAR roof heights for {len(hts):,} buildings: median {np.median(hts):.1f} m | " f"90th percentile {np.percentile(hts, 90):.1f} m | max {hts.max():.1f} m") print(f"too small to hold a 1 m cell: {n_nocell} | no roof in the LIDAR (< {MIN_HEIGHT_M:.0f} m): {n_fallback} " f"-> {FALLBACK_M:.0f} m") tallest = max(buildings, key=lambda b: b["height"]) c = tallest["geom"].centroid print(f"tallest: {tallest['tags'].get('name', tallest['id'])} {tallest['height']:.1f} m above ground " f"({c.x:,.0f} E {c.y:,.0f} N)") lv = [(float(b["tags"]["building:levels"]), b["height"]) for b in buildings if b["source"] == "lidar" and b["tags"].get("building:levels", "").replace(".", "", 1).isdigit()] lv = np.array(lv) per_storey = lv[:, 1] / lv[:, 0] print(f"buildings with building:levels and a LIDAR height: {len(lv)} | LIDAR height per storey: median " f"{np.median(per_storey):.2f} m (interquartile {np.percentile(per_storey, 25):.2f} - " f"{np.percentile(per_storey, 75):.2f} m)") # ------------------------------------------------------------------ Step 4: scale and printable footprints section("Step 4: scale and printable footprints") k = int(RES) rows, cols = dtm.shape[0] // k, dtm.shape[1] // k dem = np.nanmean(dtm.reshape(rows, k, cols, k), axis=(1, 3)) zmin = float(np.nanmin(dem)) scale = MODEL_MM / ((cols - 1) * RES) # mm per metre cell_mm = RES * scale Z = BASE_MM + (dem - zmin) * scale * Z_EXAGGERATION W_mm = D_mm = (cols - 1) * cell_mm print(f"terrain {cols} x {rows} cells of {RES:.0f} m | scale 1:{1000 / scale:,.0f} | cell {cell_mm:.3f} mm") print(f"model {W_mm:.1f} x {D_mm:.1f} mm | terrain {Z.min():.1f} - {Z.max():.1f} mm high | " f"{MIN_FEATURE_MM} mm on the print = {MIN_FEATURE_MM / scale:.1f} m on the ground") def to_mm(g): return affinity.affine_transform(g, [scale, 0, 0, scale, -(X0 + RES / 2) * scale, -(Y0 + RES / 2) * scale]) footprints = [to_mm(b["geom"]).simplify(SIMPLIFY_MM) for b in buildings] blocks = unary_union(footprints) # terraces and joined buildings become one block r = MIN_FEATURE_MM / 2 printable = blocks.buffer(-r, join_style="mitre").buffer(r, join_style="mitre").intersection( box(0.5, 0.5, W_mm - 0.5, D_mm - 0.5)) # morphological opening: removes anything thinner than 0.8 mm kept = [] for b, fp in zip(buildings, footprints): parts = [p for p in polygons(fp.intersection(printable).buffer(0)) if p.area >= MIN_AREA_MM2] if parts: b["mm"] = MultiPolygon(parts) kept.append(b) area_in, area_out = blocks.area, sum(b["mm"].area for b in kept) print(f"blocks after merging touching footprints: {len(polygons(blocks)):,}") print(f"printable: {len(kept):,} of {len(buildings):,} buildings kept | footprint area {area_out:,.0f} of " f"{area_in:,.0f} mm2 ({area_out / area_in * 100:.1f} %)") # ------------------------------------------------------------------ Step 5: flat-roofed blocks section("Step 5: flat-roofed blocks on the terrain") def prism(poly, top): """Extrude a footprint (mm) from z = 1 mm, inside the base, up to `top`: it is always sunk into the terrain.""" p = orient(poly, 1.0) cs = mf.CrossSection([np.array(p.exterior.coords)[:-1]] + [np.array(h.coords)[:-1] for h in p.interiors]) return mf.Manifold.extrude(cs, top - 1.0).translate((0, 0, 1.0)) t0 = time.perf_counter() terrain = to_manifold(*heightfield_solid(Z, cell_mm)) flat = mf.Manifold.batch_boolean([prism(p, BASE_MM + (b["ground"] + b["height"] - zmin) * scale * Z_EXAGGERATION) for b in kept for p in b["mm"].geoms], mf.OpType.Add) city_flat = terrain + flat meshes = {} for name, m in {"terrain": terrain, "flat city": city_flat}.items(): v, t = from_manifold(m) ok, vol = check_mesh(name, v, t) assert ok, name meshes[name] = (v, t, vol) print(f"[{sum(len(b['mm'].geoms) for b in kept):,} prisms, booleans and checks: {time.perf_counter() - t0:.1f} s]") # ------------------------------------------------------------------ Step 6: roofs shaped by the LIDAR section("Step 6: roofs shaped by the LIDAR surface") t0 = time.perf_counter() dsm2 = dsm.reshape(rows, k, cols, k).max(axis=(1, 3)) # block maximum keeps roof edges at roof height roof_m = np.maximum(dsm2, dem + FALLBACK_M) # every building at least one storey high Zr = BASE_MM + (roof_m - zmin) * scale * Z_EXAGGERATION surface = to_manifold(*heightfield_solid(Zr, cell_mm)) tall = mf.Manifold.batch_boolean([prism(p, Zr.max() + 1) for b in kept for p in b["mm"].geoms], mf.OpType.Add) shaped = tall ^ surface # vertical walls from OSM, roof shape from the DSM city = terrain + shaped roofs = shaped - terrain # the part of each building above the ground for name, m in {"city": city, "buildings": roofs}.items(): v, t = from_manifold(m) ok, vol = check_mesh(name, v, t) assert ok, name meshes[name] = (v, t, vol) print(f"[booleans and checks: {time.perf_counter() - t0:.1f} s]") overlap = (terrain ^ roofs).volume() print(f"terrain + buildings = {(meshes['terrain'][2] + meshes['buildings'][2]) / 1000:.3f} cm3 | " f"city = {meshes['city'][2] / 1000:.3f} cm3 | overlap between the two parts {overlap:.4f} mm3") pieces = roofs.decompose() vols = np.array([p.volume() for p in pieces]) print(f"buildings part: {len(pieces):,} separate pieces | smallest {vols.min():.3f} mm3 | " f"largest {vols.max():.1f} mm3 | top of the model {city.bounding_box()[5]:.1f} mm") cells = order[starts[buildings.index(tallest)]:starts[buildings.index(tallest) + 1]] print(f"{tallest['tags'].get('name', 'tallest building')}: flat roof {tallest['height']:.1f} m | LIDAR roof up to " f"{np.nanmax(dsm.ravel()[cells]) - tallest['ground']:.1f} m above its median ground") # ------------------------------------------------------------------ Step 7: files section("Step 7: write STL and 3MF") files = {"flat city": "durham_city_flat_roofs.stl", "city": "durham_city.stl", "terrain": "durham_terrain.stl", "buildings": "durham_buildings.stl"} for name, fn in files.items(): v, t, _ = meshes[name] write_stl(OUT / fn, v, t, f"TechnicalGIS Durham {name} - EA LIDAR, (c) OpenStreetMap contributors") size = (OUT / fn).stat().st_size with open(OUT / fn, "rb") as fh: fh.seek(80) n = int(np.frombuffer(fh.read(4), "