Skip to main content

Add 3D Model With Terrain

Use a custom style layer with three.js to add 3D models to a map with 3D terrain. Requires Trimble Maps v4.0.0 or later.

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Adding 3D models with three.js on terrain</title>
    <link rel="stylesheet" href="https://maps-sdk.trimblemaps.com/v4/trimblemaps-4.2.1.css" />
    <script src="https://maps-sdk.trimblemaps.com/v4/trimblemaps-4.2.1.js"></script>
    <style>
      body {
        margin: 0;
        padding: 0;
      }

      html,
      body,
      #map {
        height: 100%;
      }
    </style>
  </head>

  <body>
    <script src="https://unpkg.com/three@0.147.0/build/three.min.js"></script>
    <script src="https://unpkg.com/three@0.147.0/examples/js/loaders/GLTFLoader.js"></script>
    <div id="map" class="trimblemaps-map">
      <div class="trimblemaps-canvas-container trimblemaps-interactive trimblemaps-touch-drag-pan trimblemaps-touch-zoom-rotate">
        <canvas class="trimblemaps-canvas" tabindex="0" aria-label="Map" role="region" width="935" height="1316" style="width: 935px; height: 1316px"></canvas>
      </div>
      <div class="trimblemaps-control-container">
        <div class="trimblemaps-ctrl-top-left"></div>
        <div class="trimblemaps-ctrl-top-right"></div>
        <div class="trimblemaps-ctrl-bottom-left"></div>
        <div class="trimblemaps-ctrl-bottom-right">
          <div class="trimblemaps-ctrl trimblemaps-ctrl-attrib trimblemaps-compact trimblemaps-compact-show" open="">
            <div class="trimblemaps-ctrl-attrib-inner">
              <a href="https://maps.trimble.com/" target="_blank">TrimbleMaps</a> | <a href="https://developer.trimblemaps.com">© Trimble Inc.</a> - Certain POI data by Infogroup
              © 2022. | <a href="https://openmaptiles.org/" rel="nofollow">© OpenMapTiles</a>
              <a href="https://www.openstreetmap.org/copyright" rel="nofollow">© OpenStreetMap contributors</a>
            </div>
          </div>
        </div>
      </div>
    </div>
    <script>
      /**
       * Objective:
       * Given two known world-locations `model1Location` and `model2Location`,
       * place two three.js objects on those locations at the appropriate height of
       * the terrain.
       */

      async function main() {
        TrimbleMaps.setAPIKey("68B06901AF7DA34884CE5FB7A202A743");
        const THREE = window.THREE;

        const map = new TrimbleMaps.Map({
          container: "map",
          center: [11.5257, 47.668],
          zoom: 16.27,
          pitch: 60,
          bearing: -28.5,
          antialias: true,
          style: TrimbleMaps.Common.Style.TRANSPORTATION,
        });

        map.on("sourcedata", (e) => {
          if (e.sourceId === "terrainSource" && e.isSourceLoaded) {
            //enable the terrain control once terrain sources are loaded.
            map.setTerrain({
              source: "terrainSource",
              exaggeration: 1,
            });
          }
        });
        map.on("load", () => {
          // Add new sources and layers
          if (!map.getSource("hillshadeSource")) {
            map.addSource("hillshadeSource", {
              type: "raster-dem",
              tiles: ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"],
              encoding: "terrarium",
              tileSize: 256,
            });
          }
          if (!map.getSource("terrainSource")) {
            map.addSource("terrainSource", {
              type: "raster-dem",
              tiles: ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"],
              encoding: "terrarium",
              tileSize: 256,
            });
          }
          if (!map.getLayer("hills")) {
            map.addLayer(
              {
                id: "hills",
                type: "hillshade",
                source: "hillshadeSource",
                layout: { visibility: "visible" },
                paint: { "hillshade-shadow-color": "#473B24" },
              },
              "admin_boundary",
            );
          }
        });
        /*
         * Helper function used to get threejs-scene-coordinates from mercator coordinates.
         * This is just a quick and dirty solution - it won't work if points are far away from each other
         * because a meter near the north-pole covers more mercator-units
         * than a meter near the equator.
         */
        function calculateDistanceMercatorToMeters(from, to) {
          const mercatorPerMeter = from.meterInMercatorCoordinateUnits();
          // mercator x: 0=west, 1=east
          const dEast = to.x - from.x;
          const dEastMeter = dEast / mercatorPerMeter;
          // mercator y: 0=north, 1=south
          const dNorth = from.y - to.y;
          const dNorthMeter = dNorth / mercatorPerMeter;
          return { dEastMeter, dNorthMeter };
        }

        async function loadModel() {
          const loader = new THREE.GLTFLoader();
          const gltf = await loader.loadAsync("/docs/assets/34M_17/34M_17.gltf");
          const model = gltf.scene;
          return model;
        }

        // Known locations. We'll infer the elevation of those locations once terrain is loaded.
        const sceneOrigin = new TrimbleMaps.LngLat(11.5255, 47.6677);
        const model1Location = new TrimbleMaps.LngLat(11.527, 47.6678);
        const model2Location = new TrimbleMaps.LngLat(11.5249, 47.6676);

        // Configuration of the custom layer for a 3D model, implementing `CustomLayerInterface`.
        const customLayer = {
          id: "3d-model",
          type: "custom",
          renderingMode: "3d",

          onAdd(map, gl) {
            /**
             * Setting up three.js scene.
             * We're placing model1 and model2 in such a way that the whole scene fits over the terrain.
             */

            this.camera = new THREE.Camera();
            this.scene = new THREE.Scene();
            // In threejs, y points up - we're rotating the scene such that it's y points along maplibre's up.
            this.scene.rotateX(Math.PI / 2);
            // In threejs, z points toward the viewer - mirroring it such that z points along maplibre's north.
            this.scene.scale.multiply(new THREE.Vector3(1, 1, -1));
            // We now have a scene with (x=east, y=up, z=north)

            const light = new THREE.DirectionalLight(0xffffff);
            // Making it just before noon - light coming from south-east.
            light.position.set(50, 70, -30).normalize();
            this.scene.add(light);

            // Axes helper to show how threejs scene is oriented.
            const axesHelper = new THREE.AxesHelper(60);
            this.scene.add(axesHelper);

            // Getting model elevations (in meters) relative to scene origin from maplibre's terrain.
            const sceneElevation = map.queryTerrainElevation(sceneOrigin) || 0;
            const model1Elevation = map.queryTerrainElevation(model1Location) || 0;
            const model2Elevation = map.queryTerrainElevation(model2Location) || 0;
            const model1up = model1Elevation - sceneElevation;
            const model2up = model2Elevation - sceneElevation;

            // Getting model x and y (in meters) relative to scene origin.
            const sceneOriginMercator = TrimbleMaps.MercatorCoordinate.fromLngLat(sceneOrigin);
            const model1Mercator = TrimbleMaps.MercatorCoordinate.fromLngLat(model1Location);
            const model2Mercator = TrimbleMaps.MercatorCoordinate.fromLngLat(model2Location);
            const { dEastMeter: model1east, dNorthMeter: model1north } = calculateDistanceMercatorToMeters(sceneOriginMercator, model1Mercator);
            const { dEastMeter: model2east, dNorthMeter: model2north } = calculateDistanceMercatorToMeters(sceneOriginMercator, model2Mercator);

            model1.position.set(model1east, model1up, model1north);
            model2.position.set(model2east, model2up, model2north);

            this.scene.add(model1);
            this.scene.add(model2);

            // Use the TrimbleMaps JS map canvas for three.js.
            this.renderer = new THREE.WebGLRenderer({
              canvas: map.getCanvas(),
              context: gl,
              antialias: true,
            });

            this.renderer.autoClear = false;
          },

          render(gl, mercatorMatrix) {
            // `queryTerrainElevation` gives us the elevation of a point on the terrain
            // **relative to the elevation of `center`**,
            // where `center` is the point on the terrain that the middle of the camera points at.
            // If we didn't account for that offset, and the scene lay on a point on the terrain that is
            // below `center`, then the scene would appear to float in the air.
            const offsetFromCenterElevation = map.queryTerrainElevation(sceneOrigin) || 0;
            const sceneOriginMercator = TrimbleMaps.MercatorCoordinate.fromLngLat(sceneOrigin, offsetFromCenterElevation);

            const sceneTransform = {
              translateX: sceneOriginMercator.x,
              translateY: sceneOriginMercator.y,
              translateZ: sceneOriginMercator.z,
              scale: sceneOriginMercator.meterInMercatorCoordinateUnits(),
            };

            const m = new THREE.Matrix4().fromArray(mercatorMatrix);
            const l = new THREE.Matrix4()
              .makeTranslation(sceneTransform.translateX, sceneTransform.translateY, sceneTransform.translateZ)
              .scale(new THREE.Vector3(sceneTransform.scale, -sceneTransform.scale, sceneTransform.scale));

            this.camera.projectionMatrix = m.multiply(l);
            this.renderer.resetState();
            this.renderer.render(this.scene, this.camera);
            map.triggerRepaint();
          },
        };

        const results = await Promise.all([map.once("load"), loadModel()]);
        const model1 = results[1];
        const model2 = model1.clone();

        map.addLayer(customLayer);
      }

      main();
    </script>
  </body>
</html>

Last updated October 2, 2024.