Source code: Emboss a GPX route on 3D-printed terrain with Python: raised, engraved or two-colour inlay
Runnable Python scripts, requirements and console output for the tutorial “Emboss a GPX route on 3D-printed terrain with Python: raised, engraved or…
Last updated
These are the exact scripts used to produce every figure and number in Emboss a GPX route on 3D-printed terrain with Python: raised, engraved or two-colour inlay. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- gpx_route_terrain.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 gpx_route_terrain.pygpx_route_terrain.py
"""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 printablesolids: the route raised 0.6 mm above the terrain, the route engraved 0.8 mm into it, and atwo-part inlay (terrain plus route) for a multi-material printer. Every mesh is checked forwatertightness and orientation before it is written as a binary STL, and the inlay is alsowritten 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 sysimport timeimport zipfileimport xml.etree.ElementTree as ETfrom importlib.metadata import versionfrom pathlib import Path
import numpy as npimport requestsimport rasteriofrom pyproj import Transformerfrom scipy import ndimagefrom shapely.geometry import LineString, boxfrom shapely.geometry.polygon import orientfrom shapely.ops import linemergeimport manifold3d as mfimport pyvista as pvimport matplotlibmatplotlib.use("Agg")import matplotlib.pyplot as pltfrom 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 GridRES = 25.0 # metres per cell in the modelFETCH = 5.0 # metres per cell requested from the WCSTILE = 4000 # metres per WCS requestWCS = ("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) sideZ_EXAGGERATION = 2.0BASE_MM = 4.0ROUTE_WIDTH_MM = 1.2 # three 0.4 mm extrusion linesRAISE_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 helpersdef 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", "<f4", 3), ("v", "<f4", (3, 3)), ("attr", "<u2")]) rec["normal"] = np.divide(nrm, ln, out=np.zeros_like(nrm), where=ln > 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'<?xml version="1.0" encoding="UTF-8"?>\n<model unit="millimeter" xml:lang="en-GB" xmlns="{ns}">', "<resources>", '<basematerials id="1">'] xml += [f'<base name="{name}" displaycolor="{colour}FF"/>' for name, _, _, colour in parts] xml.append("</basematerials>") for i, (name, v, t, _) in enumerate(parts): xml.append(f'<object id="{i + 2}" name="{name}" type="model" pid="1" pindex="{i}"><mesh><vertices>') xml.append("".join(f'<vertex x="{x:.4f}" y="{y:.4f}" z="{z:.4f}"/>' for x, y, z in v)) xml.append("</vertices><triangles>") xml.append("".join(f'<triangle v1="{a}" v2="{b}" v3="{c}"/>' for a, b, c in t)) xml.append("</triangles></mesh></object>") xml += ["</resources>", "<build>"] + [f'<item objectid="{i + 2}"/>' for i in range(len(parts))] xml += ["</build>", "</model>"] with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: z.writestr("[Content_Types].xml", '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/' 'package/2006/content-types"><Default Extension="rels" ContentType="application/' 'vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ' 'ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/></Types>') z.writestr("_rels/.rels", '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/' 'package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" ' 'Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/></Relationships>') 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 routesection("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 worksto_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: terrainsection("Step 2: Environment Agency LIDAR DTM, fetched at 5 m and averaged to 25 m")X0, Y0, X1, Y1 = AOIk = int(RES / FETCH) # 5 x 5 blocks of 5 m cells per 25 m cellfine = np.full((int((Y1 - Y0) / FETCH), int((X1 - X0) / FETCH)), np.nan)n_tiles = 0for 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 += 1print(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] // kwith np.errstate(all="ignore"): dem = np.nanmean(fine.reshape(rows, k, cols, k), axis=(1, 3)) # area mean, not point samplesgaps = 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 profilesection("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, colelev = 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 scalesection("Step 4: model scale")scale = MODEL_LONG_MM / ((max(rows, cols) - 1) * RES) # mm per metrecell_mm = RES * scaleZ = BASE_MM + (dem - dem.min()) * scale * Z_EXAGGERATIONW_mm, D_mm = (cols - 1) * cell_mm, (rows - 1) * cell_mmprint(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: booleanssection("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 surfaceraised = terrain + ribbon # single colour, route stands proudengraved = terrain - (prism - terrain_down) # single colour, 0.8 mm grooveinlay = ribbon - terrain_down # second body that fills the groovemeshes = {}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: filessection("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), "<u4")[0]) print(f"{fn:<29s} {size / 1e6:5.1f} MB | {n:,} facets | 84 + 50 x facets = file size: {size == 84 + 50 * n}")write_3mf(OUT / "pennine_way_inlay.3mf", [("terrain", *meshes["engraved"][:2], SAND), ("route", *meshes["route inlay"][:2], RED)])with zipfile.ZipFile(OUT / "pennine_way_inlay.3mf") as z: model = ET.fromstring(z.read("3D/3dmodel.model"))objs = model.findall(".//{http://schemas.microsoft.com/3dmanufacturing/core/2015/02}object")print(f"pennine_way_inlay.3mf {(OUT / 'pennine_way_inlay.3mf').stat().st_size / 1e6:5.1f} MB | " f"parts: {', '.join(o.get('name') for o in objs)}")
# ------------------------------------------------------------------ Figuressection("figures")hs = LightSource(azdeg=315, altdeg=45).hillshade(dem, vert_exag=2, dx=RES, dy=RES)fig, ax = plt.subplots(figsize=(6.2, 7.6))ext = (X0 / 1000, X1 / 1000, Y0 / 1000, Y1 / 1000)im = ax.imshow(dem, cmap="Spectral_r", extent=ext, alpha=0.85)ax.imshow(hs, cmap="gray", extent=ext, alpha=0.35)ax.plot(E / 1000, N / 1000, color=RED, lw=2.2, label="Pennine Way (OpenStreetMap)")ax.plot(E / 1000, N / 1000, color="white", lw=0.6)for (x, y), lab, ha in [((E[0], N[0]), "Edale", "right"), ((E[-1], N[-1]), "Crowden", "right"), ((pts[top_i, 0], pts[top_i, 1]), f"highest point {elev.max():.0f} m", "left")]: ax.plot(x / 1000, y / 1000, "o", ms=6, mfc="white", mec="black") ax.annotate(lab, (x / 1000, y / 1000), xytext=(-8 if ha == "right" else 8, 4), textcoords="offset points", ha=ha, fontsize=8.5, bbox=dict(boxstyle="round,pad=0.2", fc="white", ec="none", alpha=0.8))ax.set_xlabel("Easting (km, British National Grid)")ax.set_ylabel("Northing (km)")ax.legend(loc="lower left", fontsize=8)plt.colorbar(im, ax=ax, shrink=0.6, label="Elevation (m)")ax.set_title("Pennine Way, Edale to Crowden, on the EA LIDAR DTM (25 m)")fig.savefig(OUT / "01-route-map.png"); plt.close(fig)
fig, ax = plt.subplots(figsize=(8, 3.2))ax.fill_between(d / 1000, elev, elev.min() - 30, color="#abdda4", alpha=0.6)ax.plot(d / 1000, elev, color="#3288bd", lw=1.2)ax.set_xlim(0, d[-1] / 1000); ax.set_ylim(elev.min() - 30, elev.max() + 40)ax.set_xlabel("Distance from Edale (km)"); ax.set_ylabel("Elevation (m)")sec = ax.secondary_yaxis("right", functions=(lambda e: BASE_MM + (e - dem.min()) * scale * Z_EXAGGERATION, lambda z: (z - BASE_MM) / (scale * Z_EXAGGERATION) + dem.min()))sec.set_ylabel("Height on the print (mm)")ax.annotate(f"{elev.max():.0f} m", (d[top_i] / 1000, elev.max()), xytext=(0, 6), textcoords="offset points", ha="center", fontsize=8)ax.grid(alpha=0.3)ax.set_title("Elevation profile sampled from the DTM every 25 m")fig.savefig(OUT / "02-elevation-profile.png"); plt.close(fig)
# vertical sections through the real meshes: move the y = y_cut plane to the origin, rotate it flat, slicey_cut = float(np.median(to_mm(np.array(route.coords))[:, 1]))x_hit = band.intersection(LineString([(0, y_cut), (W_mm, y_cut)])).centroid.xzc = float(Z[int(round((D_mm - y_cut) / cell_mm)), int(round(x_hit / cell_mm))])fig, axes = plt.subplots(1, 3, figsize=(9.5, 3.0), sharey=True)for ax, (title, parts) in zip(axes, [(f"Raised {RAISE_MM} mm", [(raised, SAND)]), (f"Engraved {DEPTH_MM} mm", [(engraved, SAND)]), ("Two-part inlay", [(engraved, SAND), (inlay, RED)])]): for m, colour in parts: sl = m.translate((0, -y_cut, 0)).rotate((90, 0, 0)).slice(0.0) for poly in sl.to_polygons(): p = np.vstack([poly, poly[:1]]) ax.fill(p[:, 0], -p[:, 1], color=colour, ec="#555555", lw=0.5) # the rotation maps z to -y ax.set_xlim(x_hit - 4, x_hit + 4) ax.set_title(title) ax.set_xlabel("x on the print (mm)") ax.set_aspect("equal")axes[0].set_ylabel("z (mm)")axes[0].set_ylim(zc - 3, zc + 2)fig.suptitle(f"West-east sections through the meshes where the route crosses y = {y_cut:.1f} mm", y=1.02)fig.savefig(OUT / "03-route-sections.png"); plt.close(fig)print(f"sections at y = {y_cut:.1f} mm, route centre x = {x_hit:.1f} mm, terrain z = {zc:.1f} mm")
def render(parts, png, zoom=1.0, focal=None, elev_deg=35, azim_deg=-70, size=(1600, 1150)): p = pv.Plotter(off_screen=True, window_size=size) p.set_background("white") for fn, colour in parts: p.add_mesh(pv.read(OUT / fn), color=colour, smooth_shading=False, ambient=0.25, diffuse=0.75, specular=0.1) p.enable_lightkit() c = np.array(focal if focal is not None else (W_mm / 2, D_mm / 2, 0)) dist = 1.25 * max(W_mm, D_mm) / zoom el, az = np.radians(elev_deg), np.radians(azim_deg) p.camera_position = [tuple(c + dist * np.array([np.cos(el) * np.cos(az), np.cos(el) * np.sin(az), np.sin(el)])), tuple(c), (0, 0, 1)] p.camera.view_angle = 30 p.screenshot(OUT / png) p.close()
render([(files["engraved"], SAND), (files["route inlay"], RED)], "04-stl-preview-inlay.png")fx, fy = to_mm(np.array([[pts[top_i, 0], pts[top_i, 1]]]))[0]render([(files["raised"], SAND)], "05-stl-preview-raised-closeup.png", zoom=4.5, focal=(fx, fy, float(Z.max()) - 2), elev_deg=40, azim_deg=-60)print("wrote 01-route-map, 02-elevation-profile, 03-route-sections, 04-stl-preview-inlay, " "05-stl-preview-raised-closeup")