Multi-color 3D Prints with Topoprint

Category general

In my last update, I mentioned that you can also print in multiple colors:

Multi-color prints are possible by separating land, water, buildings, and bridges. This is still an experimental feature, but looks awesome if you have a printer which can change filament easily.

I am happy to share some details about my workflow, but first, let me show you the result in the form of a captivating timelapse video that Eva Szadeczky-Kardoss kindly recorded while printing the model (click on the image to play the video).

Eva used her Bambu Lab X1 Carbon, which supports multi-color printing through an advanced multi-material system (AMS). This system automatically switches between different filament spools during the printing process, eliminating the need for manual filament changes when a new color is required. As a result, the printer can produce models with multiple colors and intricate color transitions in a single print job, as demonstrated in the video below.

To print the model, I first had to create four separate STL files: one for the base with the label, one for the land, one for the water, and one each for the buildings and bridges. The buildings and bridges are processed separately. To separate the water from the land, I used swissTLM3D and created a GeoPackage.

curl -o "swisstlm3d_2025-03_2056_5728.gpkg.zip" "https://data.geo.admin.ch/ch.swisstopo.swisstlm3d/swisstlm3d_2025-03/swisstlm3d_2025-03_2056_5728.gpkg.zip"
ogr2ogr -f GPKG swisstlm_gewaesser.gpkg.zip swisstlm3d_2025-03_2056_5728.gpkg.zip tlm_bb_bodenbedeckung -where "OBJEKTART IN ('Fliessgewaesser', 'Stehende Gewaesser')"

For the actual 3D modeling, I use PyVista to create a triangulated 3D mesh from swissALTI3D. Based on the polygons in the GeoPackage, I create both an inside and outside mesh by checking each triangle in the mesh to determine whether it resides within a water polygon or outside of it.

Click to see the Python code
def cut_mesh_with_polygons(pv_mesh, polygon_list):
    """
    Cuts a PyVista mesh into inside and outside regions based on a list of Shapely polygons.

    Parameters:
        pv_mesh (pyvista.PolyData): The input 3D mesh, should be triangulated.
        polygon_list (list): List of shapely.geometry.Polygon or MultiPolygon objects.

    Returns:
        tuple: (mesh_inside, mesh_outside) PyVista meshes.
    """
    # Input validation
    if not isinstance(polygon_list, list):
        polygon_list = [polygon_list]

    assert pv_mesh.is_all_triangles

    # Flatten MultiPolygons
    polygons = []
    for item in polygon_list:
        if isinstance(item, Polygon):
            polygons.append(item)
        elif isinstance(item, MultiPolygon):
            polygons.extend(list(item.geoms))

    # Prepare polygons for faster containment tests
    prepared_polygons = [prep(poly) for poly in polygons]

    # Extract triangles
    triangles = pv_mesh.extract_cells_by_type(pv.CellType.TRIANGLE)
    tri_points = triangles.points
    tri_indices = triangles.faces.reshape(-1, 4)[:, 1:4]

    # Calculate triangle centroids
    centroids = np.zeros((len(tri_indices), 2))
    for i, tri in enumerate(tri_indices):
        centroids[i] = np.mean(tri_points[tri][:, :2], axis=0)

    # Test if centroids are inside any polygon
    inside_mask = np.zeros(len(centroids), dtype=bool)
    for i, centroid in enumerate(centroids):
        point = Point(centroid)
        for prepared_poly in prepared_polygons:
            if prepared_poly.contains(point):
                inside_mask[i] = True
                break

    # Extract inside and outside triangles
    tri_inside = tri_indices[inside_mask]
    tri_outside = tri_indices[~inside_mask]

    # Create PyVista meshes
    def build_pv_mesh(triangles):
        if len(triangles) == 0:
            return pv.PolyData()
        # Convert to PyVista face format [3, v1, v2, v3, 3, v1, v2, v3, ...]
        faces_array = np.hstack([np.full((len(triangles), 1), 3), triangles])
        return pv.PolyData(tri_points, faces_array.ravel())

    mesh_inside = build_pv_mesh(tri_inside)
    mesh_outside = build_pv_mesh(tri_outside)

    return mesh_inside, mesh_outside

Once the STL files are created, the next step is to load them into a slicer—software that converts 3D models (like STL files) into layered instructions, known as G-code, which a 3D printer can understand and execute. My slicer of choice is OrcaSlicer. Typically, you load a single STL file; however, for multi-color prints, you can load all STL files at once. OrcaSlicer will align the STL files correctly, allowing you to choose a color for each part of the model (see the first image below). When you slice the model, it generates a G-code file. For instance, Eva's print took about 10 hours, required her Bambu printer to change the filament 240 times, and consumed a total of 60 meters of filament for the final print!

When I met Eva and she handed me the printed model in a lovely box, I was extremely happy with the result. I still enjoy looking at it and remembering my visit to that place with my family a couple of years ago.

One amusing detail about the print: each filament change comes with a cost. For every switch, the print head must "poop" out the old filament and load the new color. Doing this 240 times results in a sizable pile of waste, but there's one clever use for it: it serves as filling material in the box. Thanks again, Eva, for taking advantage of your printer! I now have an item on my Christmas wish list.

To receive site updates, subscribe to the RSS feed or the Atom feed (For an explanation about feeds, have a look at https://aboutfeeds.com).

Alternatively, you can opt to get updates via email by signing up for my newsletter, which is sent no more than once a week and only if there's an update.

Newsletter Subscription

Let me know if there is a problem with the subscription form.

\