Source: src/js/models/maps/assets/Cesium3DTileset.js

"use strict";

define([
  "cesium",
  "models/maps/assets/MapAsset",
  "models/maps/AssetColorPalette",
  "collections/maps/VectorFilters",
], function (Cesium, MapAsset, AssetColorPalette, VectorFilters) {
  /**
   * @classdesc A Cesium3DTileset Model is a special type of vector layer that can be used in
   * Cesium maps, and that follows the 3d-tiles specification. See
   * {@link https://github.com/CesiumGS/3d-tiles}
   * @classcategory Models/Maps/Assets
   * @class Cesium3DTileset
   * @name Cesium3DTileset
   * @extends MapAsset
   * @since 2.18.0
   * @constructor
   */
  var Cesium3DTileset = MapAsset.extend(
    /** @lends Cesium3DTileset.prototype */ {
      /**
       * The name of this type of model
       * @type {string}
       */
      type: "Cesium3DTileset",

      /**
       * Options that are supported for creating 3D tilesets. The object will be passed
       * to `Cesium.Cesium3DTileset(options)` as options, so the properties listed in
       * the Cesium3DTileset documentation are also supported, see
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/Cesium3DTileset.html}
       * @typedef {Object} Cesium3DTileset#cesiumOptions
       * @property {string|number} ionAssetId - If this tileset is hosted by Cesium Ion,
       * then Ion asset ID.
       * @property {string} cesiumToken - If this tileset is hosted by Cesium Ion, then
       * the token needed to access this resource. If one is not set, then the default
       * set in the repository's {@link AppConfig#cesiumToken} will be used.
       */

      /**
       * Default attributes for Cesium3DTileset models
       * @name Cesium3DTileset#defaults
       * @extends MapAsset#defaults
       * @type {Object}
       * @property {'Cesium3DTileset'} type The format of the data. Must be
       * 'Cesium3DTileset'.
       * @property {VectorFilters} [filters=new VectorFilters()] A set of conditions
       * used to show or hide specific features of this tileset.
       * @property {AssetColorPalette} [colorPalette=new AssetColorPalette()] The color
       * or colors mapped to attributes of this asset. Used to style the features and to
       * make a legend.
       * @property {Cesium.Cesium3DTileset} cesiumModel A model created and used by
       * Cesium that organizes the data to display in the Cesium Widget. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/Cesium3DTileset.html}
       * @property {Cesium3DTileset#cesiumOptions} cesiumOptions options are passed
       * to the function that creates the Cesium model. The properties of options are
       * specific to each type of asset.
       */
      defaults: function () {
        return Object.assign({}, this.constructor.__super__.defaults(), {
          type: "Cesium3DTileset",
          filters: new VectorFilters(),
          cesiumModel: null,
          cesiumOptions: {},
          colorPalette: new AssetColorPalette(),
          icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m12.6 12.8 4.9 5c.2.2.2.6 0 .8l-5 5c-.2.1-.5.1-.8 0l-4.9-5a.6.6 0 0 1 0-.8l5-5c.2-.2.5-.2.8 0ZM6.3 6.6l5 5v.7l-5 5c-.2.2-.6.2-.8 0l-5-5a.6.6 0 0 1 0-.8l5-5c.2-.1.6-.1.8 0Zm11 7.8 1.7 1.8c.3.2.3.6 0 .8l-.2.3c-.2.2-.6.2-.8 0l-1.8-1.8c-.2-.3-.2-.6 0-.9l.2-.2c.3-.2.6-.2.9 0ZM22 9.7l1.7 1.8c.3.2.3.6 0 .8l-3.3 3.4c-.2.2-.6.2-.9 0l-1.7-1.8a.6.6 0 0 1 0-.8L21 9.7c.3-.2.6-.2.9 0Zm-6-.2 1.7 1.7c.3.3.3.6 0 .9l-2 2c-.2.2-.6.2-.9 0l-1.7-1.8c-.2-.2-.3-.6 0-.8l2-2c.3-.3.6-.3.9 0ZM12.6.3l4.9 5c.2.2.2.5 0 .8l-5 4.9c-.2.2-.5.2-.8 0L6.8 6a.6.6 0 0 1 0-.8l5-4.9c.2-.2.5-.2.8 0Zm6.2 6.3 1.8 1.7c.2.3.2.7 0 1L19 10.7c-.3.3-.7.3-1 0L16.6 9c-.3-.2-.3-.6 0-1l1.4-1.3c.3-.3.7-.3 1 0Z"/></svg>',
          featureType: Cesium.Cesium3DTileFeature,
        });
      },

      /**
       * Executed when a new Cesium3DTileset model is created.
       * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the
       * attributes, which will be set on the model.
       */
      initialize: function (assetConfig) {
        try {
          MapAsset.prototype.initialize.call(this, assetConfig);

          if (assetConfig.filters) {
            this.set("filters", new VectorFilters(assetConfig.filters));
          }

          this.createCesiumModel();
        } catch (error) {
          console.log(
            "There was an error initializing a 3DTileset model" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Creates a Cesium.Cesium3DTileset model and sets it to this model's
       * 'cesiumModel' attribute. This cesiumModel contains all the information required
       * for Cesium to render tiles. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/Cesium3DTileset.html?classFilter=3Dtiles}
       * @param {Boolean} recreate - Set recreate to true to force the function create
       * the Cesium Model again. Otherwise, if a cesium model already exists, that is
       * returned instead.
       */
      createCesiumModel: function (recreate = false) {
        try {
          // If the cesium model already exists, don't create it again unless specified
          const currentModel = this.get("cesiumModel");
          if (!recreate && currentModel) return currentModel;

          const model = this;
          const cesiumOptions = this.getCesiumOptions();
          let cesiumModel = null;

          if (!cesiumOptions) {
            model.set("status", "error");
            model.set("statusDetails", "No options were set for this tileset.");
            return;
          }

          model.resetStatus();

          // If this tileset is a Cesium Ion resource set the url from the asset Id
          cesiumOptions.url =
            this.getCesiumURL(cesiumOptions) || cesiumOptions.url;

          cesiumModel = new Cesium.Cesium3DTileset(cesiumOptions);
          model.set("cesiumModel", cesiumModel);
          cesiumModel.readyPromise
            .then(function () {
              // Let the map views know that the tileset is ready to render
              model.set("status", "ready");
              // Listen to changes in the opacity, color, etc
              model.setListeners();
              // Set the initial visibility, opacity, filters, and colors
              model.updateAppearance();
            })
            .otherwise(function (error) {
              // See https://cesium.com/learn/cesiumjs/ref-doc/RequestErrorEvent.html
              let details = error;
              // Write a helpful error message
              switch (error.statusCode) {
                case 404:
                  details = "The resource was not found (error code 404).";
                  break;
                case 500:
                  details = "There was a server error (error code 500).";
                  break;
              }
              model.set("status", "error");
              model.set("statusDetails", details);
            });
        } catch (error) {
          console.log(
            "Failed to create a Cesium Model within a 3D Tileset model" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Checks whether there is an asset ID for a Cesium Ion resource and if
       * so, return the URL to the resource.
       * @returns {string} The URL to the Cesium Ion resource
       * @since 2.26.0
       */
      getCesiumURL: function () {
        try {
          const cesiumOptions = this.getCesiumOptions();
          if (!cesiumOptions || !cesiumOptions.ionAssetId) return null;
          // The Cesium Ion ID of the resource to access
          const assetId = Number(cesiumOptions.ionAssetId);
          // Options to pass to Cesium's fromAssetId function. Access token
          // needs to be set before requesting cesium ion resources
          const ionResourceOptions = {
            accessToken:
              cesiumOptions.cesiumToken ||
              MetacatUI.appModel.get("cesiumToken"),
          };
          // Create the new URL and set it on the model options
          return Cesium.IonResource.fromAssetId(assetId, ionResourceOptions);
        } catch (error) {
          console.log(
            "There was an error settings a Cesium URL in a Cesium3DTileset" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Set listeners that update the cesium model when the backbone model is updated.
       */
      setListeners: function () {
        try {
          // call the super method
          this.constructor.__super__.setListeners.call(this);

          // When opacity, color, or visibility changes (will also update the filters)
          this.stopListening(
            this,
            "change:opacity change:color change:visible",
          );
          this.listenTo(
            this,
            "change:opacity change:color change:visible",
            this.updateAppearance,
          );

          // When filters change
          this.stopListening(this.get("filters"), "update");
          this.listenTo(
            this.get("filters"),
            "update",
            this.updateFeatureVisibility,
          );
        } catch (error) {
          console.log(
            "There was an error setting listeners in a Cesium3DTileset" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Sets a new Cesium3DTileStyle on the Cesium 3D tileset model's style property,
       * based on the attributes set on this model.
       */
      updateAppearance: function () {
        try {
          const model = this;
          // The style set on the Cesium 3D tileset needs to be updated to show the
          // changes on a Cesium map.
          const cesiumModel = model.get("cesiumModel");

          if (!cesiumModel) return;

          // If the layer isn't visible at all, don't bother setting up colors or
          // filters. Just set every feature to hidden.
          if (!model.isVisible()) {
            cesiumModel.style = new Cesium.Cesium3DTileStyle({
              show: false,
            });
            // Indicate that the layer is hidden if the opacity is zero by updating the
            // visibility property
            if (model.get("opacity") === 0) {
              model.set("visible", false);
            }

            // Let the map and/or other parent views know that a change has been made
            // that requires the map to be re-rendered
            model.trigger("appearanceChanged");
          } else {
            // Set a new 3D style with a  function that Cesium will use to shade each
            // feature.
            cesiumModel.style = new Cesium.Cesium3DTileStyle({
              color: {
                evaluateColor: model.getColorFunction(),
              },
            });
            // Since the style has to be reset, re-add the filters expression. This also
            // triggers the appearanceChanged event
            model.updateFeatureVisibility();
          }
        } catch (error) {
          console.log(
            "There was an error updating a 3D Tile style property in a Cesium3DTileset" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Updates the Cesium style set on this tileset. Adds a new expression to the
       * show property that will filter the features based on the filters set on this
       * model.
       */
      updateFeatureVisibility: function () {
        try {
          const model = this;
          const cesiumModel = this.get("cesiumModel");
          const filters = this.get("filters");

          // If there are no filters, just set the show property to true
          if (!filters || !filters.length) {
            cesiumModel.style.show = true;
          } else {
            const expression = new Cesium.StyleExpression();
            expression.evaluate = function (feature) {
              const properties = model.getPropertiesFromFeature(feature);
              return model.featureIsVisible(properties);
            };
            cesiumModel.style.show = expression;
          }
          model.trigger("appearanceChanged");
        } catch (error) {
          console.log(
            "There was an error  in a Cesium3DTileset" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Given a feature from a Cesium 3D tileset, returns any properties that are set
       * on the feature, similar to an attributes table.
       * @param {Cesium.Cesium3DTileFeature} feature A Cesium 3D Tile feature
       * @returns {Object} An object containing key-value mapping of property names to
       * properties.
       */
      getPropertiesFromFeature: function (feature) {
        if (!this.usesFeatureType(feature)) return null;
        let properties = {};
        feature.getPropertyNames().forEach(function (propertyName) {
          properties[propertyName] = feature.getProperty(propertyName);
        });
        properties = this.addCustomProperties(properties);
        return properties;
      },

      /**
       * Return the label for a feature from a Cesium 3D tileset
       * @param {Cesium.Cesium3DTileFeature} feature A Cesium 3D Tile feature
       * @returns {string} The label
       * @since 2.25.0
       */
      getLabelFromFeature: function (feature) {
        if (!this.usesFeatureType(feature)) return null;
        return (
          feature.getProperty("name") || feature.getProperty("label") || null
        );
      },

      /**
       * Return the Cesium3DTileset model for a feature from a Cesium 3D tileset
       * @param {Cesium.Cesium3DTileFeature} feature A Cesium 3D Tile feature
       * @returns {Cesium3DTileset} The model
       * @since 2.25.0
       */
      getCesiumModelFromFeature: function (feature) {
        if (!this.usesFeatureType(feature)) return null;
        return feature.primitive;
      },

      /**
       * Return the ID used by Cesium for a feature from a Cesium 3D tileset
       * @param {Cesium.Cesium3DTileFeature} feature A Cesium 3D Tile feature
       * @returns {string} The ID
       * @since 2.25.0
       */
      getIDFromFeature: function (feature) {
        if (!this.usesFeatureType(feature)) return null;
        return feature.pickId ? feature.pickId.key : null;
      },

      /**
       * Creates a function that takes a Cesium3DTileFeature (see
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/Cesium3DTileFeature.html}) and
       * returns a Cesium color based on the colorPalette property set on this model.
       * The returned function is designed to be used as the evaluateColor function that
       * is set in the color property of a Cesium3DTileStyle StyleExpression. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/Cesium3DTileStyle.html#color}
       * @returns {function} A Cesium 3dTile evaluate color function
       */
      getColorFunction: function () {
        try {
          const model = this;
          // Opacity of the entire layer is set by using it as the alpha for each color
          const opacity = model.get("opacity");

          const evaluateColor = function (feature) {
            const properties = model.getPropertiesFromFeature(feature);
            let featureOpacity = opacity;
            // If the feature is currently selected, set the opacity to max (otherwise the
            // 'silhouette' borders in the map do not show in the Cesium widget)
            if (model.featureIsSelected(feature)) {
              featureOpacity = 1;
            }
            const rgb = model.getColor(properties);
            if (rgb) {
              return new Cesium.Color(
                rgb.red,
                rgb.green,
                rgb.blue,
                featureOpacity,
              );
            } else {
              return new Cesium.Color();
            }
          };
          return evaluateColor;
        } catch (error) {
          console.log(
            "There was an error creating a color function in a Cesium3DTileset model" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Gets a Cesium Bounding Sphere that can be used to navigate to view the full
       * extent of the tileset. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html}.
       * @returns {Promise} Returns a promise that resolves to a Cesium Bounding Sphere
       * when ready
       */
      getBoundingSphere: function () {
        const model = this;
        return this.whenReady().then(function (model) {
          const tileset = model.get("cesiumModel");
          const bSphere = Cesium.BoundingSphere.clone(tileset.boundingSphere);
          return bSphere;
        });
      },
    },
  );

  return Cesium3DTileset;
});