Display any 3D Tiles from a url.

Parameters
URL

Error target
100%
index.js
/*
 * Copyright (c) 2015-2018, IGN France.
 * Copyright (c) 2018-2026, Giro3D team.
 * SPDX-License-Identifier: MIT
 */

import { Toast } from "bootstrap";
import OSM from "ol/source/OSM.js";
import { AmbientLight, Color, DirectionalLight, Mesh, Vector3 } from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";

import GlobeControls from "@giro3d/giro3d/controls/GlobeControls.js";
import CoordinateSystem from "@giro3d/giro3d/core/geographic/CoordinateSystem.js";
import Instance from "@giro3d/giro3d/core/Instance.js";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer.js";
import Globe from "@giro3d/giro3d/entities/Globe.js";
import Tiles3D from "@giro3d/giro3d/entities/Tiles3D.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import TiledImageSource from "@giro3d/giro3d/sources/TiledImageSource.js";

/*
 * Copyright (c) 2015-2018, IGN France.
 * Copyright (c) 2018-2026, Giro3D team.
 * SPDX-License-Identifier: MIT
 */

function bindButton(id, onClick) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLButtonElement)) {
    throw new Error(
      "invalid binding element: expected HTMLButtonElement, got: " +
        element.constructor.name,
    );
  }

  element.onclick = () => {
    onClick(element);
  };

  return element;
}

/*
 * Copyright (c) 2015-2018, IGN France.
 * Copyright (c) 2018-2026, Giro3D team.
 * SPDX-License-Identifier: MIT
 */

function bindNumberInput(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLInputElement, got: " +
        element.constructor.name,
    );
  }

  element.onchange = () => {
    onChange(element.valueAsNumber);
  };

  const callback = (v) => {
    element.value = v.toString();
    onChange(element.valueAsNumber);
  };

  return [callback, element.valueAsNumber, element];
}

/*
 * Copyright (c) 2015-2018, IGN France.
 * Copyright (c) 2018-2026, Giro3D team.
 * SPDX-License-Identifier: MIT
 */

function bindSlider(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLInputElement, got: " +
        element.constructor.name,
    );
  }

  element.oninput = function oninput() {
    onChange(element.valueAsNumber);
  };

  const setValue = (v, min, max, step) => {
    if (min != null && max != null) {
      element.min = min.toString();
      element.max = max.toString();

      if (step != null) {
        element.step = step;
      }
    }
    element.valueAsNumber = v;
    onChange(element.valueAsNumber);
  };

  const initialValue = element.valueAsNumber;

  return [setValue, initialValue, element];
}

/*
 * Copyright (c) 2015-2018, IGN France.
 * Copyright (c) 2018-2026, Giro3D team.
 * SPDX-License-Identifier: MIT
 */

function bindToggle(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLButtonElement, got: " +
        element.constructor.name,
    );
  }

  element.oninput = function oninput() {
    onChange(element.checked);
  };

  const callback = (v) => {
    element.checked = v;
    onChange(element.checked);
  };

  return [callback, element.checked, element];
}

function resolveTilesetUrl() {
  const searchParam = new URL(document.URL).searchParams.get("url");

  const input = document.getElementById("url");
  const inputValue = input.value;

  if (inputValue) {
    const value = input.value;
    const url = new URL(document.URL);

    url.searchParams.delete("url");
    url.searchParams.append("url", value);

    window.history.replaceState({}, null, url.toString());

    return value;
  } else if (searchParam) {
    input.value = searchParam;
    return searchParam;
  } else {
    // TODO defaults ?
  }
}

const params = {
  globeMode: false,
  globeOpacity: 1,
  tilesetOpacity: 1,
  errorTarget: 8,
};

let tileset = undefined;

let instance = undefined;

let globe = undefined;

let inspector = undefined;

function initControls(target) {
  if (params.globeMode) {
    const globeControls = new GlobeControls({
      scene: globe.object3d,
      ellipsoid: globe.ellipsoid,
      camera: instance.view.camera,
      domElement: instance.domElement,
    });

    const updateControls = () => {
      globeControls.update();
      instance.notifyChange(globe);
      instance.notifyChange(tileset);

      requestAnimationFrame(updateControls);
    };

    updateControls();
  } else {
    // create controls
    const mapControls = new MapControls(
      instance.view.camera,
      instance.domElement,
    );
    mapControls.enableDamping = true;
    mapControls.dampingFactor = 0.25;
    mapControls.update();
    mapControls.target.copy(target);
    instance.view.setControls(mapControls);
  }
}

// setup the error displaying
const toastLiveExample = document.getElementById("liveToast");
const toastBootstrap = Toast.getOrCreateInstance(toastLiveExample);
function displayError(evt) {
  document.getElementById("error").innerText = evt.error.message;
  toastBootstrap.show();
}

async function run(url) {
  instance?.dispose();
  inspector?.detach();

  // init instance
  instance = new Instance({
    target: "view", // The id of the <div> to attach the instance
    crs: params.globeMode
      ? CoordinateSystem.epsg4978
      : CoordinateSystem.epsg3857,
    backgroundColor: 0xcccccc,
  });

  if (params.globeMode) {
    globe = new Globe({
      backgroundColor: "#aad3df",
    });

    globe.helperColor = "black";

    await instance.add(globe);

    const layer = new ColorLayer({
      source: new TiledImageSource({ source: new OSM() }),
    });

    await globe.addLayer(layer);
  }

  // Add a sunlight
  const sun = new DirectionalLight("#ffffff", 1.4);
  sun.position.set(1, 0, 1).normalize();
  sun.updateMatrixWorld(true);
  instance.scene.add(sun);

  // We can look below the floor, so let's light also a bit there
  const sun2 = new DirectionalLight("#ffffff", 0.5);
  sun2.position.set(0, -1, 1);
  sun2.updateMatrixWorld();
  instance.scene.add(sun2);

  // Add ambient light
  const ambientLight = new AmbientLight(0xffffff, 1);
  instance.scene.add(ambientLight);
  instance.view.minNearPlane = 0.5;

  tileset = new Tiles3D({ url: url.toString() });

  // If the tileset comes from an ifc converted with py3dtiles, hide some elements that don't bring visual value
  tileset.addEventListener("object-created", (evt) => {
    const scene = evt.obj;
    scene.traverse((obj) => {
      if (obj.userData?.class === "IfcSpace") {
        obj.visible = false;
        instance.notifyChange();
      }
    });
  });

  await instance.add(tileset);

  const pov = instance.view.goTo(tileset);

  initControls(pov.target);

  inspector = Inspector.attach("inspector", instance);
}

run(resolveTilesetUrl()).catch(console.error);

// picking and highlighting logic
const resultsTable = document.getElementById("results");

let highlighted;
let highlightColor = new Color(0xff7171);

let canPick = true;

function highlight(evt) {
  if (!instance || !tileset) {
    return;
  }

  if (!canPick) {
    return;
  }

  const picked = instance.pickObjectsAt(evt, {
    radius: 5,
    limit: 10,
    where: [tileset],
    filter: (pick) => pick.object.visible, // Ignore invisible objects, such as IfcSpace elements
  });

  if (highlighted && highlighted.material.color != null) {
    // reset style
    const material = highlighted.material;
    material.color.copy(material.userData.oldColor);

    instance.notifyChange(highlighted);
  }

  if (picked.length === 0) {
    document.getElementById("pick-result").style.display = "none";
  } else {
    document.getElementById("pick-result").style.display = "block";
    const obj = picked[0].object;
    if (obj instanceof Mesh) {
      const material = obj.material;

      // keep the old color to reset it later
      if (material.color != null) {
        if (!material.userData.oldColor) {
          material.userData.oldColor = material.color.clone();
        }

        material.color.copy(highlightColor);
      }

      instance.notifyChange(obj);
      highlighted = obj;
    }

    const rows = [];

    for (const [name, value] of Object.entries(obj.userData)) {
      if (name !== "oldColor" && name !== "parentEntity") {
        const row = document.createElement("tr");
        const nameCell = document.createElement("td");
        nameCell.innerHTML = `<code>${name}</code>`;
        const valueCell = document.createElement("td");
        valueCell.innerText = value.toString().substring(0, 20);
        valueCell.title = value;
        row.append(nameCell, valueCell);
        rows.push(row);
      }
    }

    resultsTable.replaceChildren(...rows);
  }
}

// Prevent picking if user is dragging mouse
instance.domElement.addEventListener("mousedown", () => (canPick = true));
instance.domElement.addEventListener("mousemove", () => (canPick = false));
instance.domElement.addEventListener("mouseup", highlight);

function updateParams() {
  if (globe) {
    globe.opacity = params.globeOpacity;
    globe.visible = params.globeOpacity > 0;
    instance.notifyChange(globe);
  }
  if (tileset) {
    tileset.opacity = params.tilesetOpacity;
    tileset.visible = params.tilesetOpacity > 0;
    tileset.errorTarget = params.errorTarget;
    instance.notifyChange(tileset);
  }
}

updateParams();

bindSlider("globe-opacity", (v) => {
  params.globeOpacity = v;
  updateParams();
});
bindSlider("tileset-opacity", (v) => {
  params.tilesetOpacity = v;
  updateParams();
});
bindButton("center-view", () => {
  if (tileset) {
    instance.view.goTo(tileset);
  }
});
bindToggle("globe-mode", (v) => {
  params.globeMode = v;
});
bindNumberInput("error-target", (v) => {
  params.errorTarget = v;
  updateParams();
});
bindButton("start", () => {
  run(resolveTilesetUrl()).catch(console.error);
});
index.html
<!doctype html>
<html lang="en">
  <head>
    <title>3D Tiles Simple Viewer</title>
    <meta charset="UTF-8" />
    <meta name="name" content="3d-tiles-simple-viewer" />
    <meta name="description" content="Display any 3D Tiles from a url." />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <link rel="icon" href="https://giro3d.org/images/favicon.svg" />
    <link
      href="https://giro3d.org/assets/bootstrap-custom.css"
      rel="stylesheet"
    />
    <script src="https://giro3d.org/assets/bootstrap.bundle.min.js"></script>
    <link
      rel="stylesheet"
      type="text/css"
      href="https://giro3d.org/latest/examples/css/example.css"
    />
  </head>

  <body>
    <div id="view" class="m-0 p-0 w-100 h-100"></div>
    <div
      id="inspector"
      class="position-absolute top-0 start-0 mh-100 overflow-auto"
    ></div>

    <div class="side-pane-with-status-bar pe-none">
      <div class="card pe-auto">
        <div class="card-header">Parameters</div>

        <div class="card-body">
          <!-- Tileset URL -->
          <div class="input-group">
            <span class="input-group-text" id="url-label">URL</span>
            <input
              type="url"
              class="form-control"
              id="url"
              placeholder="https://3d.oslandia.com/3dtiles/19_rue_Marc_Antoine_Petit_ifc/tileset.json"
              value="https://3d.oslandia.com/3dtiles/19_rue_Marc_Antoine_Petit_ifc/tileset.json"
              aria-label="URL"
              autocomplete="off"
              aria-describedby="url"
            />
          </div>

          <!-- Enable globe mode -->
          <div class="form-check form-switch mt-3">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="globe-mode"
              autocomplete="off"
            />
            <label class="form-check-label" for="globe-mode">Globe mode</label>
          </div>

          <button class="btn btn-primary w-100 mt-3" id="start">
            <i class="bi bi-caret-right-fill"></i>
            Load tileset
          </button>

          <hr />

          <label for="globe-opacity" id="label-globe-opacity" class="form-label"
            >Globe opacity</label
          >
          <div class="input-group">
            <input
              type="range"
              min="0"
              max="1"
              step="0.01"
              value="1"
              class="form-range"
              id="globe-opacity"
              autocomplete="off"
            />
          </div>
          <label
            for="tileset-opacity"
            id="label-tileset-opacity"
            class="form-label"
            >Tileset opacity</label
          >
          <div class="input-group">
            <input
              type="range"
              min="0"
              max="1"
              step="0.01"
              value="1"
              class="form-range"
              id="tileset-opacity"
              autocomplete="off"
            />
          </div>

          <!-- Tileset URL -->
          <div class="input-group mt-3">
            <span class="input-group-text" id="error-target-label"
              >Error target</span
            >
            <input
              type="number"
              class="form-control"
              id="error-target"
              value="8"
              aria-label="Error target"
              autocomplete="off"
              aria-describedby="error-target"
            />
          </div>

          <!-- Center view -->
          <button
            type="button"
            class="btn btn-primary w-100 mt-3"
            id="center-view"
          >
            <i class="bi bi-eye-fill"></i>
            Center view
          </button>
        </div>
      </div>
      <div class="card pe-auto mt-3" id="pick-result" style="display: none">
        <div class="card-header">Selected object</div>

        <div class="card-body">
          <table class="table">
            <thead>
              <tr>
                <th scope="col">Name</th>
                <th scope="col">Value</th>
              </tr>
            </thead>
            <tbody id="results">
              <tr>
                <td>?</td>
                <td>?</td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>

    <script type="module" src="index.js"></script>
    <script>
      /* activate popovers */
      const popoverTriggerList = [].slice.call(
        document.querySelectorAll('[data-bs-toggle="popover"]'),
      );
      popoverTriggerList.map(
        // bootstrap is used as script in the template, disable warning about undef
        // eslint-disable-next-line no-undef
        (popoverTriggerEl) =>
          new bootstrap.Popover(popoverTriggerEl, {
            trigger: "hover",
            placement: "left",
            content: document.getElementById(
              popoverTriggerEl.getAttribute("data-bs-content"),
            ).innerHTML,
            html: true,
          }),
      );
    </script>
  </body>
</html>
package.json
{
    "name": "3d-tiles-simple-viewer",
    "dependencies": {
        "@giro3d/giro3d": "3.0.0-dev"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}
vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    target: 'esnext',
  },
  optimizeDeps: {
    esbuildOptions: {
      target: 'esnext',
    },
  },
})