Skip to content

Add 3D Buildings

The building layer of the Maptoolkit vector tile schema carries the fields needed to extrude footprints into 3D: height and min_height in metres, an extrude flag, and colour_index for varying the fill.

The Street style already includes a fill-extrusion layer and shows 3D buildings from zoom 14 without any work. Use this example when you want 3D buildings in a style that does not ship them, which is Summer, Light, Dark, Hiking and Cycling.

Two things worth knowing before you copy it. Filter on extrude being true: not every footprint is meant to be extruded, and the flag is what the Street style itself filters on. And set fill-extrusion-base from min_height, otherwise buildings that start above ground level, such as an upper storey bridging a street, sink into the terrain.

import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.0.0/dist/maplibre-gl.mjs';
import { MaptoolkitLogoControl } from 'https://cdn.jsdelivr.net/npm/@maptoolkit/maplibre-gl-logo/+esm';
const map = new maplibregl.Map({
  container: 'map',
  attributionControl: { customAttribution: '', compact: false },
  style: 'https://styles.maptoolkit.org/summer.json',
  center: [-0.12025, 51.50222],
  zoom: 15.5,
  pitch: 60,
  bearing: 45
});

// Arabic/Hebrew: MapLibre has no right-to-left support built in, this plugin adds it
maplibregl.setRTLTextPlugin('https://cdn.jsdelivr.net/npm/@mapbox/mapbox-gl-rtl-text@0.4.0/dist/mapbox-gl-rtl-text.js', false);

map.on('load', function () {
  // Insert the extrusion below the label layers, so street and place names
  // stay on top of the buildings instead of being hidden behind them.
  const layers = map.getStyle().layers;
  let firstLabel;
  for (const layer of layers) {
    if (layer.type === 'symbol') { firstLabel = layer.id; break; }
  }

  map.addLayer({
    id: 'buildings-3d',
    type: 'fill-extrusion',
    source: 'mtk',
    'source-layer': 'building',
    minzoom: 14,
    // Only footprints the data marks as extrudable. This is the same filter
    // the Street style uses.
    filter: ['==', ['get', 'extrude'], true],
    paint: {
      'fill-extrusion-height': ['coalesce', ['get', 'height'], 8],
      // min_height matters: without it, anything starting above ground
      // level is drawn from the ground up and looks wrong.
      'fill-extrusion-base': ['coalesce', ['get', 'min_height'], 0],
      'fill-extrusion-color': [
        'case',
        ['has', 'colour_index'],
        ['interpolate', ['linear'], ['get', 'colour_index'],
          0, 'hsl(45, 37%, 88%)',
          5, 'hsl(45, 18%, 92%)',
          10, 'hsl(45, 22%, 85%)'
        ],
        'hsl(45, 22%, 85%)'
      ],
      'fill-extrusion-vertical-gradient': true,
      // Fade the extrusion in rather than having it appear at zoom 14.
      'fill-extrusion-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0.2, 14.5, 0.9]
    }
  }, firstLabel);
});

map.addControl(new MaptoolkitLogoControl());
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.0.0/dist/maplibre-gl.css" />
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
  </style>
</head>
<body>
  <div id="map"></div>
  <script type="module">
import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.0.0/dist/maplibre-gl.mjs';
import { MaptoolkitLogoControl } from 'https://cdn.jsdelivr.net/npm/@maptoolkit/maplibre-gl-logo/+esm';
const map = new maplibregl.Map({
  container: 'map',
  attributionControl: { customAttribution: '', compact: false },
  style: 'https://styles.maptoolkit.org/summer.json',
  center: [-0.12025, 51.50222],
  zoom: 15.5,
  pitch: 60,
  bearing: 45
});

// Arabic/Hebrew: MapLibre has no right-to-left support built in, this plugin adds it
maplibregl.setRTLTextPlugin('https://cdn.jsdelivr.net/npm/@mapbox/mapbox-gl-rtl-text@0.4.0/dist/mapbox-gl-rtl-text.js', false);

map.on('load', function () {
  // Insert the extrusion below the label layers, so street and place names
  // stay on top of the buildings instead of being hidden behind them.
  const layers = map.getStyle().layers;
  let firstLabel;
  for (const layer of layers) {
    if (layer.type === 'symbol') { firstLabel = layer.id; break; }
  }

  map.addLayer({
    id: 'buildings-3d',
    type: 'fill-extrusion',
    source: 'mtk',
    'source-layer': 'building',
    minzoom: 14,
    // Only footprints the data marks as extrudable. This is the same filter
    // the Street style uses.
    filter: ['==', ['get', 'extrude'], true],
    paint: {
      'fill-extrusion-height': ['coalesce', ['get', 'height'], 8],
      // min_height matters: without it, anything starting above ground
      // level is drawn from the ground up and looks wrong.
      'fill-extrusion-base': ['coalesce', ['get', 'min_height'], 0],
      'fill-extrusion-color': [
        'case',
        ['has', 'colour_index'],
        ['interpolate', ['linear'], ['get', 'colour_index'],
          0, 'hsl(45, 37%, 88%)',
          5, 'hsl(45, 18%, 92%)',
          10, 'hsl(45, 22%, 85%)'
        ],
        'hsl(45, 22%, 85%)'
      ],
      'fill-extrusion-vertical-gradient': true,
      // Fade the extrusion in rather than having it appear at zoom 14.
      'fill-extrusion-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0.2, 14.5, 0.9]
    }
  }, firstLabel);
});

map.addControl(new MaptoolkitLogoControl());
  </script>
</body>
</html>

Fields on the building layer

height and min_height are the two you need for a basic extrusion. The rest are there for renderers doing more detailed 3D work than a flat-topped prism.

FieldTypeWhat it is
heightNumberTotal building height in metres
min_heightNumberHeight of the underside above ground, in metres
extrudeBooleanWhether this footprint is meant to be extruded
buildingStringThe building classification from OpenStreetMap
building_colourStringBuilding colour where it is mapped
colour_indexNumberIndex used to vary the fill across neighbouring buildings
roof_shapeStringRoof form, for example gabled, hipped, pyramidal
roof_angleStringRoof pitch
roof_directionStringDirection the roof faces
roof_orientationStringWhether the ridge runs along or across the footprint
roof_heightStringRoof height
roof_levelsStringNumber of levels inside the roof
roof_materialStringRoof material
roof_colourStringRoof colour where it is mapped

MapLibre’s fill-extrusion cannot express a pitched roof, so the roof fields are only useful if you are rendering geometry yourself, in Deck.gl or CesiumJS for instance. Coverage of all of these depends on what OpenStreetMap contributors have mapped locally, and it varies a lot between cities.

Further Reading

  • Schema Reference - the full vector tile schema.
  • Map Styles - the style catalog, including the Street style that ships 3D buildings.
  • 3D Styles - the -3d variants, which add terrain relief rather than buildings.