Source code: Multi-colour 3D-printed terrain with Python: one STL per colour for AMS and MMU printers
Runnable Python scripts, requirements and console output for the tutorial “Multi-colour 3D-printed terrain with Python: one STL per colour for AMS and…
Last updated
These are the exact scripts used to produce every figure and number in Multi-colour 3D-printed terrain with Python: one STL per colour for AMS and MMU printers. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- multi_colour_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 multi_colour_terrain.pymulti_colour_terrain.py
"""Multi-colour 3D-printed terrain: split a model of Yr Wyddfa (Snowdon) into one STL per colour.
Copernicus GLO-30 terrain and ESA WorldCover land cover are put on one 20 m British National Grid,classified into four printable colours (water, woodland, open ground below 700 m, high groundabove 700 m), cleaned so that no colour region is too small to print, and cut into four watertightparts that fit together exactly. The parts are written as separate STLs and as one 3MF formulti-material printers such as a Bambu Lab AMS or a Prusa MMU.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/multi-colour-3d-print-terrain/
Data: Copernicus DEM GLO-30 (© DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018,provided under COPERNICUS by the European Union and ESA); ESA WorldCover 10 m 2021 v200(© ESA WorldCover project 2021, contains modified Copernicus Sentinel data (2021) processed by theESA WorldCover consortium, CC BY 4.0)."""import sysimport timeimport zipfileimport xml.etree.ElementTree as ETfrom importlib.metadata import versionfrom pathlib import Path
import numpy as npimport rasteriofrom rasterio import featuresfrom rasterio.transform import from_originfrom rasterio.warp import reproject, Resamplingfrom scipy import ndimagefrom shapely.geometry import shapefrom shapely.geometry.polygon import orientimport manifold3d as mfimport pyvista as pvimport matplotlibmatplotlib.use("Agg")import matplotlib.pyplot as pltfrom matplotlib.colors import ListedColormap, LightSource, BoundaryNormfrom matplotlib.patches import Patch
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9, "figure.facecolor": "white", "axes.facecolor": "white"})pv.OFF_SCREEN = TrueOUT = Path("output"); OUT.mkdir(exist_ok=True)
DEM_URL = ("https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N53_00_W005_00_DEM/" "Copernicus_DSM_COG_10_N53_00_W005_00_DEM.tif")WC_URL = ("https://esa-worldcover.s3.eu-central-1.amazonaws.com/v200/2021/map/" "ESA_WorldCover_10m_2021_v200_N51W006_Map.tif")AOI = (256_500, 350_000, 264_500, 357_000) # 8 x 7 km around Yr Wyddfa, British National GridRES = 20.0X0, Y0, X1, Y1 = AOIDST = from_origin(X0, Y1, RES, RES)ROWS, COLS = int((Y1 - Y0) / RES), int((X1 - X0) / RES)
MODEL_LONG_MM = 150.0Z_EXAGGERATION = 1.5BASE_MM = 3.0HIGH_M = 700.0 # open ground above this is "high ground"WATER_DROP_MM = 0.4 # lakes sit a little below their shoresMIN_REGION_MM2 = 1.5 # smallest colour island worth printing (about 3 x 3 nozzle widths)CAP_MM = 1.2 # thickness of the coloured skin in design BLAYER_MM = 0.2
# 0 water, 1 woodland, 2 open ground, 3 high ground; colours from the Spectral paletteNAMES = ["water", "woodland", "open ground", "high ground"]COLOURS = ["#3288bd", "#1a9850", "#abdda4", "#f2efe6"]EDGE = "#555555"
def section(title): print(f"\n=== {title} ===")
# ------------------------------------------------------------------ mesh helpersdef 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): 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): 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):>8,} triangles | watertight {bool((und == 2).all())} | " f"oriented {bool((drc == 1).all())} | volume {vol / 1000:6.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 count_regions(lab): return {NAMES[k]: int(ndimage.label(lab == k)[1]) for k in range(len(NAMES))}
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: two rasters, one gridsection("Step 1: DEM and land cover on one 20 m grid")t0 = time.perf_counter()dem = np.full((ROWS, COLS), np.nan, dtype="float32")wc = np.zeros((ROWS, COLS), dtype="uint8")with rasterio.Env(GDAL_HTTP_MAX_RETRY="4", GDAL_HTTP_RETRY_DELAY="5"): with rasterio.open(DEM_URL) as src: reproject(rasterio.band(src, 1), dem, dst_transform=DST, dst_crs="EPSG:27700", resampling=Resampling.bilinear, dst_nodata=np.nan) with rasterio.open(WC_URL) as src: reproject(rasterio.band(src, 1), wc, dst_transform=DST, dst_crs="EPSG:27700", resampling=Resampling.mode)print(f"grid {COLS} x {ROWS} cells of {RES:.0f} m | read and reprojected in {time.perf_counter() - t0:.1f} s")print(f"DEM: {np.nanmin(dem):.1f} - {np.nanmax(dem):.1f} m, NaNs {int(np.isnan(dem).sum())}")wc_names = {10: "tree cover", 20: "shrubland", 30: "grassland", 40: "cropland", 50: "built-up", 60: "bare / sparse", 80: "permanent water", 90: "herbaceous wetland", 95: "mangroves", 100: "moss and lichen"}vals, cnt = np.unique(wc, return_counts=True)print("WorldCover classes: " + ", ".join(f"{wc_names.get(v, v)} {c / wc.size * 100:.1f}%" for v, c in zip(vals, cnt)))
# ------------------------------------------------------------------ Step 2: four printable colourssection("Step 2: classify into four colours and remove unprintable specks")lab = np.where(dem >= HIGH_M, 3, 2).astype("uint8")lab[wc == 10] = 1lab[wc == 80] = 0raw = lab.copy()scale = MODEL_LONG_MM / ((max(ROWS, COLS) - 1) * RES) # mm per metrecell_mm = RES * scalemin_px = int(np.ceil(MIN_REGION_MM2 / cell_mm ** 2))lab = features.sieve(lab, size=min_px, connectivity=4)print(f"cell on the print {cell_mm:.3f} mm -> minimum region {MIN_REGION_MM2} mm2 = {min_px} cells")before, after = count_regions(raw), count_regions(lab)for k, name in enumerate(NAMES): print(f"{name:<12s} regions {before[name]:>4d} -> {after[name]:>3d} | " f"area {np.mean(raw == k) * 100:5.1f}% -> {np.mean(lab == k) * 100:5.1f}%")print(f"cells changed by the sieve: {int((raw != lab).sum()):,} ({np.mean(raw != lab) * 100:.2f}%)")
# ------------------------------------------------------------------ Step 3: the terrain solidsection("Step 3: terrain solid")Z = BASE_MM + (dem - dem.min()) * scale * Z_EXAGGERATIONwater = lab == 0Z[water] -= WATER_DROP_MMW_mm, D_mm = (COLS - 1) * cell_mm, (ROWS - 1) * cell_mmprint(f"horizontal scale 1:{1000 / scale:,.0f} | model {W_mm:.1f} x {D_mm:.1f} x {Z.max():.1f} mm")print(f"lakes lowered by {WATER_DROP_MM} mm at {int(water.sum()):,} vertices")z_high = BASE_MM + (HIGH_M - dem.min()) * scale * Z_EXAGGERATIONprint(f"single-extruder alternative: change filament at z = {z_high:.2f} mm for ground above {HIGH_M:.0f} m")terrain = to_manifold(*heightfield_solid(Z, cell_mm))
# ------------------------------------------------------------------ Step 4: cut one part per coloursection("Step 4: one watertight part per colour")# pixel (r, c) is centred on the mesh vertex at x = c * cell, y = (ROWS - 1 - r) * cellpx_to_mm = rasterio.Affine(cell_mm, 0, -cell_mm / 2, 0, -cell_mm, (ROWS - 0.5) * cell_mm)
def class_prism(k): contours = [] for geom, _ in features.shapes(lab, mask=lab == k, transform=px_to_mm, connectivity=4): poly = orient(shape(geom), 1.0) # outer rings anticlockwise, holes clockwise contours += [np.array(poly.exterior.coords)[:-1]] + [np.array(h.coords)[:-1] for h in poly.interiors] return mf.Manifold.extrude(mf.CrossSection(contours), Z.max() + 10).translate((0, 0, -5))
BODY = NAMES.index("open ground") # the most common colour carries the whole modelprisms = {k: class_prism(k) for k in range(len(NAMES)) if k != BODY}skin = terrain - to_manifold(*heightfield_solid(Z - CAP_MM, cell_mm)) # the top CAP_MM of the terrain
# design A: every colour runs from the bed to the surfacefull = {NAMES[k]: terrain ^ pr for k, pr in prisms.items()}# design B: colours are a CAP_MM skin over a single-colour bodycaps = {NAMES[k]: skin ^ pr for k, pr in prisms.items()}for design in (full, caps): # the body is whatever the other colours leave behind design[NAMES[BODY]] = terrain - mf.Manifold.batch_boolean( [m for name, m in design.items() if name != NAMES[BODY]], mf.OpType.Add)
def layers_with_swaps(parts, layer=LAYER_MM): zs = np.arange(layer / 2, Z.max(), layer) n_colours = np.array([sum(m.slice(z).area() > 0.01 for m in parts.values()) for z in zs]) return len(zs), int((n_colours > 1).sum())
for label, design in [("A: full-depth colours", full), (f"B: {CAP_MM} mm colour caps", caps)]: n, multi = layers_with_swaps(design) accent = sum(m.volume() for name, m in design.items() if name != NAMES[BODY]) / 1000 print(f"{label:<24s} accent colours {accent:5.2f} cm3 | {multi:>3d} of {n} layers ({LAYER_MM} mm) " f"need more than one filament")
def drop_slivers(m, min_mm3=0.05): """Booleans can leave zero-volume slivers where two regions touch only at a corner.""" pieces = m.decompose() keep = [p for p in pieces if p.volume() >= min_mm3] return mf.Manifold.batch_boolean(keep, mf.OpType.Add), len(pieces) - len(keep), sum(p.volume() for p in pieces) - sum(p.volume() for p in keep)
parts = {}for name in NAMES: parts[name], n_drop, v_drop = drop_slivers(caps[name]) print(f"{name:<12s} dropped {n_drop:>3d} slivers totalling {v_drop:.4f} mm3")
meshes = {}for name, m in parts.items(): v, t = from_manifold(m) ok, vol = check_mesh(name, v, t) assert ok, name meshes[name] = (v, t, vol)total = sum(m[2] for m in meshes.values())names = list(parts)overlap = sum((parts[a] ^ parts[b]).volume() for i, a in enumerate(names) for b in names[i + 1:])print(f"sum of parts {total / 1000:.3f} cm3 | whole terrain {terrain.volume() / 1000:.3f} cm3 | " f"pairwise overlap {overlap:.4f} mm3")for name, m in parts.items(): print(f"{name:<12s} {len(m.decompose()):>3d} separate piece(s)")
# ------------------------------------------------------------------ Step 5: filessection("Step 5: write STLs and a 3MF")for k, name in enumerate(NAMES): v, t, _ = meshes[name] fn = f"yr_wyddfa_{k + 1}_{name.replace(' ', '_')}.stl" write_stl(OUT / fn, v, t, f"TechnicalGIS Yr Wyddfa {name} - Copernicus GLO-30, ESA WorldCover") print(f"{fn:<34s} {(OUT / fn).stat().st_size / 1e6:5.1f} MB")write_3mf(OUT / "yr_wyddfa_multicolour.3mf", [(name, *meshes[name][:2], COLOURS[k]) for k, name in enumerate(NAMES)])with zipfile.ZipFile(OUT / "yr_wyddfa_multicolour.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"yr_wyddfa_multicolour.3mf {(OUT / 'yr_wyddfa_multicolour.3mf').stat().st_size / 1e6:5.1f} MB | " f"parts: {', '.join(o.get('name') for o in objs)}")
# ------------------------------------------------------------------ Figuressection("figures")ext = (X0 / 1000, X1 / 1000, Y0 / 1000, Y1 / 1000)hs = LightSource(azdeg=315, altdeg=45).hillshade(dem, vert_exag=1.5, dx=RES, dy=RES)wc_cols = {10: "#1a9850", 20: "#a6d96a", 30: "#d9ef8b", 60: "#bababa", 80: "#3288bd", 90: "#66c2a5", 50: "#d53e4f", 40: "#fee08b", 100: "#e6f598"}present = [v for v in vals if v in wc_cols]wc_idx = np.searchsorted(present, wc)fig, axes = plt.subplots(1, 2, figsize=(10, 4.4))im = axes[0].imshow(dem, cmap="Spectral_r", extent=ext)axes[0].imshow(hs, cmap="gray", extent=ext, alpha=0.35)plt.colorbar(im, ax=axes[0], shrink=0.8, label="Elevation (m)")axes[0].set_title("Copernicus GLO-30, reprojected to 20 m")axes[1].imshow(wc_idx, cmap=ListedColormap([wc_cols[v] for v in present]), extent=ext, interpolation="nearest")axes[1].imshow(hs, cmap="gray", extent=ext, alpha=0.25)axes[1].legend(handles=[Patch(color=wc_cols[v], label=wc_names[v]) for v in present], fontsize=7, loc="upper left", bbox_to_anchor=(1.01, 1))axes[1].set_title("ESA WorldCover 2021, mode-resampled to 20 m")for ax in axes: ax.set_xlabel("Easting (km, BNG)")axes[0].set_ylabel("Northing (km)")fig.savefig(OUT / "01-dem-and-land-cover.png"); plt.close(fig)
cmap4 = ListedColormap(COLOURS)norm4 = BoundaryNorm(np.arange(-0.5, 4.5), 4)fig, axes = plt.subplots(1, 2, figsize=(10, 4.4))for ax, arr, title, counts in [(axes[0], raw, "Before: raw classes", before), (axes[1], lab, f"After: regions under {MIN_REGION_MM2} mm² on the print removed", after)]: ax.imshow(arr, cmap=cmap4, norm=norm4, extent=ext, interpolation="nearest") ax.imshow(hs, cmap="gray", extent=ext, alpha=0.2) ax.set_title(f"{title}\n{sum(counts.values()):,} colour regions") ax.set_xlabel("Easting (km, BNG)")axes[0].set_ylabel("Northing (km)")axes[1].legend(handles=[Patch(color=c, label=n) for n, c in zip(NAMES, COLOURS)], fontsize=8, loc="upper left", bbox_to_anchor=(1.01, 1))fig.savefig(OUT / "02-colour-regions-sieve.png"); plt.close(fig)
def stl_path(k): return OUT / f"yr_wyddfa_{k + 1}_{NAMES[k].replace(' ', '_')}.stl"
def place_camera(p, elev_deg=35, azim_deg=-60, dist_factor=2.0): c = np.array((W_mm / 2, D_mm / 2, Z.max() / 3)) el, az = np.radians(elev_deg), np.radians(azim_deg) eye = c + dist_factor * max(W_mm, D_mm) * np.array([np.cos(el) * np.cos(az), np.cos(el) * np.sin(az), np.sin(el)]) p.camera_position = [tuple(eye), tuple(c), (0, 0, 1)] p.camera.view_angle = 30
def save_cropped(p, png, pad=20): img = p.screenshot(return_img=True) ink = np.where((img < 250).any(axis=2)) r0, r1 = max(ink[0].min() - pad, 0), min(ink[0].max() + pad, img.shape[0]) c0, c1 = max(ink[1].min() - pad, 0), min(ink[1].max() + pad, img.shape[1]) plt.imsave(OUT / png, img[r0:r1, c0:c1]) p.close()
# hero: the four STLs read back from disk and shown together, as the slicer will see themp = pv.Plotter(off_screen=True, window_size=(1800, 1300))p.set_background("white")for k in range(len(NAMES)): p.add_mesh(pv.read(stl_path(k)), color=COLOURS[k], ambient=0.25, diffuse=0.75, specular=0.1)p.enable_lightkit()place_camera(p)save_cropped(p, "03-stl-preview-multicolour.png")
# one panel per part, each read from its own STL, inside the outline of the full modelbox = pv.Box(bounds=(0, W_mm, 0, D_mm, 0, float(Z.max()))).extract_feature_edges()p = pv.Plotter(off_screen=True, window_size=(1600, 1200), shape=(2, 2), border=False)for k, name in enumerate(NAMES): p.subplot(k // 2, k % 2) p.set_background("white") p.add_mesh(pv.read(stl_path(k)), color=COLOURS[k], ambient=0.3, diffuse=0.7) p.add_mesh(box, color="#aaaaaa", line_width=1) p.add_text(f"{k + 1}. {name}", font_size=14, color="#222222", position="upper_left") p.enable_lightkit() place_camera(p, dist_factor=2.1)save_cropped(p, "04-stl-parts.png")print("wrote 01-dem-and-land-cover, 02-colour-regions-sieve, 03-stl-preview-multicolour, 04-stl-parts")