Display a point cloud colored by a combination of several attributes

Loading metadata...
100% Autzen stadium dataset provided by United States Geological Survey and Hobu, Inc.
index.js
import { Color } from "three";

import ColorMap from "@giro3d/giro3d/core/ColorMap.js";
import CoordinateSystem from "@giro3d/giro3d/core/geographic/CoordinateSystem.js";
import Instance from "@giro3d/giro3d/core/Instance.js";
import PointCloud from "@giro3d/giro3d/entities/PointCloud.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import COPCSource from "@giro3d/giro3d/sources/COPCSource.js";
import { setLazPerfPath } from "@giro3d/giro3d/sources/las/config.js";

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

  const setProgress = (normalized, text) => {
    element.style.width = `${Math.round(normalized * 100)}%`;
    if (text) {
      element.innerText = text;
    }
  };

  return [setProgress, element.parentElement];
}

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];
}

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 placeCameraOnTop(volume, instance) {
  if (!instance) {
    return;
  }

  const center = volume.getCenter(new Vector3());
  const size = volume.getSize(new Vector3());

  const camera = instance.view.camera;
  const top = volume.max.z;
  const fov = camera.fov;
  const aspect = camera.aspect;

  const hFov = MathUtils.degToRad(fov) / 2;
  const altitude = (Math.max(size.x / aspect, size.y) / Math.tan(hFov)) * 0.5;

  instance.view.camera.position.set(center.x, center.y - 1, altitude + top);
  instance.view.camera.lookAt(center);

  const controls = new MapControls(instance.view.camera, instance.domElement);
  controls.target.copy(center);
  controls.enableDamping = true;
  controls.dampingFactor = 0.25;

  instance.view.setControls(controls);
  instance.notifyChange(instance.view.camera);
}

// LAS processing requires the WebAssembly laz-perf library
// This path is specific to your project, and must be set accordingly.
setLazPerfPath("/assets/wasm");

// We use this CRS when the point cloud does not have a CRS defined.
// It is technically the WebMercator CRS, but we label it 'unknown' to make
// it very explicit that it is not correct.
// See https://gitlab.com/giro3d/giro3d/-/issues/514
CoordinateSystem.register(
  "unknown",
  "+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs +type=crs",
);

let instance;

let entity;

const [setProgress, progressElement] = bindProgress("progress");

bindToggle("edl", (v) => {
  instance.renderingOptions.enableEDL = v;
  instance.notifyChange();
});

bindToggle("inpainting", (v) => {
  instance.renderingOptions.enableInpainting = v;
  instance.renderingOptions.enablePointCloudOcclusion = v;
  instance.notifyChange();
});

bindSlider("point-size", (size) => {
  if (entity) {
    entity.pointSize = size;
    document.getElementById("point-size-label").innerHTML =
      `Point size: <b>${size === 0 ? "auto" : size.toFixed(0)}</b>`;
  }
});
bindSlider("subdivision-threshold", (threshold) => {
  if (entity) {
    entity.subdivisionThreshold = threshold;
    document.getElementById("subdivision-threshold-label").innerHTML =
      `Subdivision threshold: <b>${threshold}</b>`;
  }
});

const colorWeightRange = document.getElementById("color-weight");

const zWeightRange = document.getElementById("z-weight");

const classificationWeightRange = document.getElementById(
  "classification-weight",
);

async function fetchCrsDefinitionFromEpsg(code) {
  async function fetchText(url) {
    const res = await fetch(url, { mode: "cors" });
    const def = await res.text();
    return def;
  }

  return await fetchText(`https://epsg.io/${code}.proj4?download=1`);
}

// Loads the point cloud from the url parameter
async function load(url) {
  progressElement.style.display = "block";

  // Let's create the source
  const source = new COPCSource({ url });

  source.addEventListener("progress", () => setProgress(source.progress));

  try {
    // Initialize the source in advance, so that we can
    // access the metadata of the remote LAS file.
    await source.initialize();
  } catch (err) {
    if (err instanceof Error) {
      const messageElement = document.getElementById("message");
      messageElement.innerText = err.message;
      messageElement.style.display = "block";
    }
    progressElement.style.display = "none";
    console.error(err);
    return;
  }

  const metadata = await source.getMetadata();

  instance = new Instance({
    target: "view",
    crs: metadata.crs,
    backgroundColor: null,
  });

  // Let's enable Eye Dome Lighting to make the point cloud more readable.
  instance.renderingOptions.enableEDL = true;
  instance.renderingOptions.EDLRadius = 0.6;
  instance.renderingOptions.EDLStrength = 5;

  // Let's create our point cloud with the COPC source.
  entity = new PointCloud({ source });

  await instance.add(entity);

  // Create a black to white color ramp for the "Z" attribute
  const zAttribute = entity
    .getSupportedAttributes()
    .find((att) => att.name === "Z");
  const colors = [];
  for (let i = 0; i < 255; i++) {
    const v = i / 255;
    colors.push(new Color(v, v, v));
  }
  const colorMap = new ColorMap({
    min: zAttribute.min,
    max: zAttribute.max,
    colors,
  });
  entity.elevationColorMap = colorMap;
  entity.setAttributeColorMap("Z", colorMap);

  const onWeightsUpdate = () => {
    const colorWeight = +colorWeightRange.value;
    const zWeight = +zWeightRange.value;
    const classificationWeight = +classificationWeightRange.value;

    const totalWeight = colorWeight + zWeight + classificationWeight;
    if (totalWeight === 0) {
      // default to color
      colorWeightRange.value = "1";
    }

    colorWeightRange.disabled = zWeight === 0 && classificationWeight === 0;
    zWeightRange.disabled = colorWeight === 0 && classificationWeight === 0;
    classificationWeightRange.disabled = colorWeight === 0 && zWeight === 0;

    if (colorWeightRange.disabled) {
      colorWeightRange.value = "1";
    }
    if (zWeightRange.disabled) {
      zWeightRange.value = "1";
    }
    if (classificationWeightRange.disabled) {
      classificationWeightRange.value = "1";
    }

    entity.setActiveAttributes([
      { name: "Color", weight: +colorWeightRange.value },
      { name: "Z", weight: +zWeightRange.value },
      { name: "Classification", weight: +classificationWeightRange.value },
    ]);
  };

  colorWeightRange.addEventListener("input", onWeightsUpdate);
  zWeightRange.addEventListener("input", onWeightsUpdate);
  classificationWeightRange.addEventListener("input", onWeightsUpdate);
  onWeightsUpdate();

  // Let's get the volume of the point cloud for various operations.
  const volume = entity.getBoundingBox();

  // If the source provides a coordinate system, we can load a map
  // to display as a geographic context and be able to check that the
  // point cloud is properly positioned.
  const epsgCode = metadata.crs.srid?.tryGetEpsgCode();
  if (typeof epsgCode === "number") {
    try {
      const definitionFromEpsg = await fetchCrsDefinitionFromEpsg(epsgCode);
      CoordinateSystem.register(metadata.crs.id, definitionFromEpsg);
    } catch (e) {
      console.warn("could not load map: " + e);
    }
  }

  document.getElementById("accordion").style.display = "block";
  progressElement.style.display = "none";

  Inspector.attach("inspector", instance);

  if (instance.coordinateSystem.srid) {
  }

  placeCameraOnTop(volume, instance);

  instance.notifyChange();
}

const datasetUrl =
  "https://3d.oslandia.com/giro3d/pointclouds/autzen-classified.copc.laz";
load(datasetUrl).catch(console.error);
index.html
<!doctype html>
<html lang="en">
  <head>
    <title>PointCloud Hybrid Coloring</title>
    <meta charset="UTF-8" />
    <meta name="name" content="point-cloud-hybrid-coloring" />
    <meta
      name="description"
      content="Display a point cloud colored by a combination of several attributes"
    />
    <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"
    />

    <style>
      #view canvas {
        background: rgb(132, 170, 182);
        background: radial-gradient(
          circle,
          rgba(132, 170, 182, 1) 0%,
          rgba(37, 44, 48, 1) 100%
        );
      }
    </style>
  </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" style="width: 20rem">
      <div class="progress" role="progressbar">
        <div
          class="progress-bar bg-info progress-bar-striped progress-bar-animated text-dark"
          id="progress"
          style="width: 0%"
        >
          Loading metadata...
        </div>
      </div>

      <!-- Error message -->
      <div
        class="alert alert-danger mt-0 mb-0"
        id="message"
        style="display: none"
        role="alert"
      >
        A simple primary alert—check it out!
      </div>

      <!--Parameters -->
      <div class="card-body">
        <!-- Accordion -->
        <div class="accordion" style="display: none" id="accordion">
          <!-- Section: options -->
          <div class="accordion-item">
            <h2 class="accordion-header">
              <button
                class="accordion-button"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#section-options"
                aria-expanded="false"
                aria-controls="section-options"
              >
                Options
              </button>
            </h2>

            <div
              id="section-options"
              class="accordion-collapse collapse p-2"
              data-bs-parent="#accordion"
            >
              <!-- Eye Dome Lighting -->
              <div class="form-check form-switch">
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  checked
                  id="edl"
                  autocomplete="off"
                />
                <label
                  title="Toggles Eye Dome Lighting post-processing effect"
                  class="form-check-label"
                  for="edl"
                  >Eye Dome Lighting</label
                >
              </div>

              <!-- Inpainting -->
              <div class="form-check form-switch">
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  id="inpainting"
                  autocomplete="off"
                />
                <label
                  title="Toggles inpainting"
                  class="form-check-label"
                  for="inpainting"
                  >Inpainting</label
                >
              </div>

              <!-- Point size slider -->
              <label
                for="point-size"
                class="form-label mt-2"
                id="point-size-label"
                >Point size: <b>auto</b></label
              >
              <input
                type="range"
                min="0"
                max="50"
                step="1"
                value="0"
                title="The point size, in pixels"
                class="form-range"
                id="point-size"
                autocomplete="off"
              />

              <!-- Subdivision threshold slider -->
              <label
                for="subdivision-threshold"
                id="subdivision-threshold-label"
                class="form-label"
                >Subdvision threshold: <b>1.0</b></label
              >
              <input
                type="range"
                min="0.1"
                max="3"
                step="0.1"
                value="1"
                title="The subdivision threshold of the point cloud. The lower, the higher the number of points simultaneously displayed."
                class="form-range"
                id="subdivision-threshold"
                autocomplete="off"
              />
            </div>
          </div>

          <!-- Section: coloring -->
          <div class="accordion-item">
            <h2 class="accordion-header">
              <button
                class="accordion-button"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#section-coloring"
                aria-expanded="true"
                aria-controls="section-coloring"
              >
                Coloring
              </button>
            </h2>

            <div
              id="section-coloring"
              class="accordion-collapse collapse p-2 show"
              data-bs-parent="#accordion"
            >
              <fieldset class="border p-2 mb-3">
                <legend class="float-none w-auto form-text mb-0 px-2">
                  "Color" attribute
                </legend>
                <label class="d-flex">
                  <span class="me-3">Weight</span>
                  <input
                    type="range"
                    min="0"
                    max="1"
                    step="0.01"
                    value="0.5"
                    title="The subdivision threshold of the point cloud. The lower, the higher the number of points simultaneously displayed."
                    class="form-range"
                    id="color-weight"
                    autocomplete="off"
                  />
                </label>
              </fieldset>
              <fieldset class="border p-2 mb-3">
                <legend class="float-none w-auto form-text mb-0 px-2">
                  "Z" attribute
                </legend>
                <div>
                  <label class="d-flex">
                    <span class="me-3">Weight</span>
                    <input
                      type="range"
                      min="0"
                      max="1"
                      step="0.01"
                      value="0"
                      title="The subdivision threshold of the point cloud. The lower, the higher the number of points simultaneously displayed."
                      class="form-range"
                      id="z-weight"
                      autocomplete="off"
                    />
                  </label>
                </div>
              </fieldset>
              <fieldset class="border p-2 mb-3">
                <legend class="float-none w-auto form-text mb-0 px-2">
                  "Classification" attribute
                </legend>
                <div>
                  <label class="d-flex">
                    <span class="me-3">Weight</span>
                    <input
                      type="range"
                      min="0"
                      max="1"
                      step="0.01"
                      value="1"
                      title="The subdivision threshold of the point cloud. The lower, the higher the number of points simultaneously displayed."
                      class="form-range"
                      id="classification-weight"
                      autocomplete="off"
                    />
                  </label>
                </div>
              </fieldset>
            </div>
          </div>
        </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": "point-cloud-hybrid-coloring",
    "dependencies": {
        "@giro3d/giro3d": "1.0.0"
    },
    "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',
    },
  },
})