"""Emboss a GPX route on a 3D-printable terrain model: the Pennine Way from Edale to Crowden. Reads a GPX track, drapes it over Environment Agency LIDAR terrain, and writes three printable solids: the route raised 0.6 mm above the terrain, the route engraved 0.8 mm into it, and a two-part inlay (terrain plus route) for a multi-material printer. Every mesh is checked for watertightness and orientation before it is written as a binary STL, and the inlay is also written as one 3MF file with two parts. TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/gpx-route-3d-print-terrain/ Data: Environment Agency LIDAR Composite DTM 1 m via the Defra WCS (Open Government Licence v3); the Pennine Way (Edale to Crowden) route relation from OpenStreetMap (© OpenStreetMap contributors, Open Database Licence). """ 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 from pyproj import Transformer from scipy import ndimage from shapely.geometry import LineString, box from shapely.geometry.polygon import orient from shapely.ops import linemerge import manifold3d as mf import pyvista as pv import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.colors import LightSource 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)"} RELATION = 4080403 # OSM: "Pennine Way (Edale to Crowden)" AOI = (403_000, 384_000, 415_000, 400_000) # 12 x 16 km, British National Grid RES = 25.0 # metres per cell in the model FETCH = 5.0 # metres per cell requested from the WCS TILE = 4000 # metres per WCS request WCS = ("https://environment.data.gov.uk/spatialdata/lidar-composite-digital-terrain-model-dtm-1m/wcs" "?service=WCS&version=2.0.1&request=GetCoverage&format=image/tiff" "&CoverageId=13787b9a-26a4-4775-8523-806d13af58fc__Lidar_Composite_Elevation_DTM_1m") MODEL_LONG_MM = 170.0 # printed length of the longer (north-south) side Z_EXAGGERATION = 2.0 BASE_MM = 4.0 ROUTE_WIDTH_MM = 1.2 # three 0.4 mm extrusion lines RAISE_MM = 0.6 # route height above the terrain (three 0.2 mm layers) DEPTH_MM = 0.8 # groove depth below the terrain RED, SAND = "#d53e4f", "#e8dcc0" 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 # ------------------------------------------------------------------ mesh helpers def grid_triangles(r, c): """Two anticlockwise (from above) triangles per grid cell, as in the DEM-to-STL tutorial.""" 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:<12s} {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 read_gpx(path): """Return an (n, 2) array of lon/lat from the track points (or route points) of any GPX 1.0/1.1 file.""" root = ET.parse(path).getroot() ns = root.tag[1:].split("}")[0] if root.tag.startswith("{") else "" q = (lambda t: f"{{{ns}}}{t}") if ns else (lambda t: t) pts = list(root.iter(q("trkpt"))) or list(root.iter(q("rtept"))) return np.array([[float(p.get("lon")), float(p.get("lat"))] for p in pts]) 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__}") # ------------------------------------------------------------------ Step 1: a GPX route section("Step 1: the route as GPX") osm = requests.get(f"https://api.openstreetmap.org/api/0.6/relation/{RELATION}/full.json", timeout=180, headers=UA).json()["elements"] nodes = {e["id"]: (e["lon"], e["lat"]) for e in osm if e["type"] == "node"} ways = {e["id"]: e["nodes"] for e in osm if e["type"] == "way"} rel = next(e for e in osm if e["type"] == "relation") line = linemerge([LineString([nodes[n] for n in ways[m["ref"]]]) for m in rel["members"] if m["type"] == "way"]) print(f"OSM relation {RELATION}: '{rel['tags']['name']}', {len(ways)} ways -> {line.geom_type}") coords = np.array(line.coords) if coords[0, 1] > coords[-1, 1]: # walk it south to north: Edale first coords = coords[::-1] gpx = ET.Element("gpx", version="1.1", creator="TechnicalGIS", xmlns="http://www.topografix.com/GPX/1/1") trk = ET.SubElement(gpx, "trk") ET.SubElement(trk, "name").text = rel["tags"]["name"] seg = ET.SubElement(trk, "trkseg") for lon, lat in coords: ET.SubElement(seg, "trkpt", lat=f"{lat:.7f}", lon=f"{lon:.7f}") gpx_path = DATA / "pennine_way_edale_crowden.gpx" ET.ElementTree(gpx).write(gpx_path, xml_declaration=True, encoding="UTF-8") lonlat = read_gpx(gpx_path) # from here on, any GPX file works to_bng = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True) E, N = to_bng.transform(lonlat[:, 0], lonlat[:, 1]) route = LineString(np.column_stack([E, N])) print(f"read {gpx_path.name}: {len(lonlat):,} track points") print(f"start {E[0]:,.0f} E {N[0]:,.0f} N | end {E[-1]:,.0f} E {N[-1]:,.0f} N | length {route.length / 1000:.2f} km") print(f"inside the model area: {box(*AOI).contains(route)}") # ------------------------------------------------------------------ Step 2: terrain section("Step 2: Environment Agency LIDAR DTM, fetched at 5 m and averaged to 25 m") X0, Y0, X1, Y1 = AOI k = int(RES / FETCH) # 5 x 5 blocks of 5 m cells per 25 m cell fine = np.full((int((Y1 - Y0) / FETCH), int((X1 - X0) / FETCH)), np.nan) n_tiles = 0 for x in range(X0, X1, TILE): for y in range(Y0, Y1, TILE): f = get(f"{WCS}&subset=E({x},{x + TILE})&subset=N({y},{y + TILE})&SCALEFACTOR={1 / FETCH}", DATA / f"ea_dtm_{x}_{y}_{TILE}m_tile_{FETCH:.0f}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) / FETCH)), int(round((src.bounds.left - X0) / FETCH)) fine[r:r + a.shape[0], c:c + a.shape[1]] = a n_tiles += 1 print(f"{n_tiles} WCS tiles -> {fine.shape[1]} x {fine.shape[0]} cells of {FETCH:.0f} m | " f"no-data: {np.isnan(fine).sum():,}") rows, cols = fine.shape[0] // k, fine.shape[1] // k with np.errstate(all="ignore"): dem = np.nanmean(fine.reshape(rows, k, cols, k), axis=(1, 3)) # area mean, not point samples gaps = np.isnan(dem) print(f"block mean {k} x {k} -> {cols} x {rows} cells of {RES:.0f} m | empty cells filled: {gaps.sum():,}") dem = dem[tuple(ndimage.distance_transform_edt(gaps, return_distances=False, return_indices=True))] print(f"elevation {dem.min():.1f} - {dem.max():.1f} m") # ------------------------------------------------------------------ Step 3: drape and profile section("Step 3: drape the route on the DEM") d = np.arange(0, route.length, RES) pts = np.array([route.interpolate(s).coords[0] for s in d]) rc = np.vstack([(Y1 - pts[:, 1]) / RES - 0.5, (pts[:, 0] - X0) / RES - 0.5]) # fractional row, col elev = ndimage.map_coordinates(dem, rc, order=1) climb = np.diff(elev) top_i = int(np.argmax(elev)) print(f"sampled every {RES:.0f} m: {len(d):,} points") print(f"lowest {elev.min():.0f} m | highest {elev.max():.0f} m at {d[top_i] / 1000:.1f} km " f"({pts[top_i, 0]:,.0f} E {pts[top_i, 1]:,.0f} N)") print(f"total ascent {climb[climb > 0].sum():,.0f} m | total descent {-climb[climb < 0].sum():,.0f} m") # ------------------------------------------------------------------ Step 4: model scale section("Step 4: model scale") scale = MODEL_LONG_MM / ((max(rows, cols) - 1) * RES) # mm per metre cell_mm = RES * scale Z = BASE_MM + (dem - dem.min()) * scale * Z_EXAGGERATION W_mm, D_mm = (cols - 1) * cell_mm, (rows - 1) * cell_mm print(f"horizontal scale 1:{1000 / scale:,.0f} | cell on the print {cell_mm:.3f} mm") print(f"model {W_mm:.1f} x {D_mm:.1f} x {Z.max():.1f} mm | route {ROUTE_WIDTH_MM} mm wide = " f"{ROUTE_WIDTH_MM / scale:.0f} m on the ground") def to_mm(xy): return np.column_stack([(xy[:, 0] - (X0 + RES / 2)) * scale, (xy[:, 1] - (Y0 + RES / 2)) * scale]) route_mm = LineString(to_mm(np.array(route.coords))).simplify(0.02) band = route_mm.buffer(ROUTE_WIDTH_MM / 2, quad_segs=6).intersection(box(0.5, 0.5, W_mm - 0.5, D_mm - 0.5)) band = orient(band, 1.0) contours = [np.array(band.exterior.coords)[:-1]] + [np.array(h.coords)[:-1] for h in band.interiors] print(f"route line: {len(route.coords):,} -> {len(route_mm.coords):,} vertices after a 0.02 mm simplify") print(f"route outline: {band.geom_type} with {sum(len(c) for c in contours):,} vertices, " f"{len(band.interiors)} holes, area {band.area:.1f} mm2") # ------------------------------------------------------------------ Step 5: booleans section("Step 5: raised, engraved and inlay solids") t0 = time.perf_counter() terrain = to_manifold(*heightfield_solid(Z, cell_mm)) terrain_up = to_manifold(*heightfield_solid(Z + RAISE_MM, cell_mm)) terrain_down = to_manifold(*heightfield_solid(Z - DEPTH_MM, cell_mm)) prism = mf.Manifold.extrude(mf.CrossSection(contours), Z.max() + 10).translate((0, 0, -5)) ribbon = prism ^ terrain_up # the route column, cut off 0.6 mm above the surface raised = terrain + ribbon # single colour, route stands proud engraved = terrain - (prism - terrain_down) # single colour, 0.8 mm groove inlay = ribbon - terrain_down # second body that fills the groove meshes = {} for name, m in {"terrain": terrain, "raised": raised, "engraved": engraved, "route inlay": inlay}.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 = (engraved ^ inlay).volume() print(f"engraved + inlay = {(meshes['engraved'][2] + meshes['route inlay'][2]) / 1000:.3f} cm3 | " f"raised = {meshes['raised'][2] / 1000:.3f} cm3 | overlap between the two parts {overlap:.4f} mm3") print(f"raised - terrain = {(meshes['raised'][2] - meshes['terrain'][2]):.1f} mm3 " f"(route area {band.area:.1f} mm2 x {RAISE_MM} mm = {band.area * RAISE_MM:.1f} mm3)") print(f"route inlay: {len(inlay.decompose())} piece(s)") # ------------------------------------------------------------------ Step 6: files section("Step 6: write STL and 3MF") files = {"raised": "pennine_way_raised.stl", "engraved": "pennine_way_engraved.stl", "route inlay": "pennine_way_route_inlay.stl"} for name, fn in files.items(): v, t, _ = meshes[name] write_stl(OUT / fn, v, t, f"TechnicalGIS Pennine Way {name} - EA LIDAR DTM, (c) OSM contributors") size = (OUT / fn).stat().st_size with open(OUT / fn, "rb") as fh: fh.seek(80) n = int(np.frombuffer(fh.read(4), "