terriajs
Version:
Geospatial data visualization platform.
657 lines (587 loc) • 22.1 kB
text/typescript
import i18next from "i18next";
import {
action,
computed,
isObservableArray,
observable,
runInAction,
toJS
} from "mobx";
import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2";
import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3";
import clone from "terriajs-cesium/Source/Core/clone";
import Color from "terriajs-cesium/Source/Core/Color";
import HeadingPitchRoll from "terriajs-cesium/Source/Core/HeadingPitchRoll";
import IonResource from "terriajs-cesium/Source/Core/IonResource";
import Matrix3 from "terriajs-cesium/Source/Core/Matrix3";
import Matrix4 from "terriajs-cesium/Source/Core/Matrix4";
import Quaternion from "terriajs-cesium/Source/Core/Quaternion";
import Resource from "terriajs-cesium/Source/Core/Resource";
import Transforms from "terriajs-cesium/Source/Core/Transforms";
import Cesium3DTileColorBlendMode from "terriajs-cesium/Source/Scene/Cesium3DTileColorBlendMode";
import Cesium3DTileFeature from "terriajs-cesium/Source/Scene/Cesium3DTileFeature";
import Cesium3DTilePointFeature from "terriajs-cesium/Source/Scene/Cesium3DTilePointFeature";
import Cesium3DTileset from "terriajs-cesium/Source/Scene/Cesium3DTileset";
import Cesium3DTileStyle from "terriajs-cesium/Source/Scene/Cesium3DTileStyle";
import Constructor from "../Core/Constructor";
import isDefined from "../Core/isDefined";
import { isJsonObject, JsonObject } from "../Core/Json";
import runLater from "../Core/runLater";
import TerriaError from "../Core/TerriaError";
import proxyCatalogItemUrl from "../Models/Catalog/proxyCatalogItemUrl";
import CommonStrata from "../Models/Definition/CommonStrata";
import createStratumInstance from "../Models/Definition/createStratumInstance";
import LoadableStratum from "../Models/Definition/LoadableStratum";
import Model, { BaseModel } from "../Models/Definition/Model";
import StratumOrder from "../Models/Definition/StratumOrder";
import TerriaFeature from "../Models/Feature/Feature";
import Cesium3DTilesCatalogItemTraits from "../Traits/TraitsClasses/Cesium3DTilesCatalogItemTraits";
import Cesium3dTilesTraits, {
OptionsTraits
} from "../Traits/TraitsClasses/Cesium3dTilesTraits";
import CatalogMemberMixin, { getName } from "./CatalogMemberMixin";
import ClippingMixin from "./ClippingMixin";
import MappableMixin from "./MappableMixin";
import ShadowMixin from "./ShadowMixin";
class Cesium3dTilesStratum extends LoadableStratum(Cesium3dTilesTraits) {
duplicateLoadableStratum(model: BaseModel): this {
return new Cesium3dTilesStratum() as this;
}
get opacity() {
return 1.0;
}
}
// Register the Cesium3dTilesStratum
StratumOrder.instance.addLoadStratum(Cesium3dTilesStratum.name);
const DEFAULT_HIGHLIGHT_COLOR = "#ff3f00";
interface Cesium3DTilesCatalogItemIface
extends InstanceType<ReturnType<typeof Cesium3dTilesMixin>> {}
class ObservableCesium3DTileset extends Cesium3DTileset {
_catalogItem?: Cesium3DTilesCatalogItemIface;
destroyed = false;
destroy() {
super.destroy();
// TODO: we are running later to prevent this
// modification from happening in some computed up the call chain.
// Figure out why that is happening and fix it.
runLater(() => {
runInAction(() => {
this.destroyed = true;
});
});
}
}
function Cesium3dTilesMixin<T extends Constructor<Model<Cesium3dTilesTraits>>>(
Base: T
) {
abstract class Cesium3dTilesMixin extends ClippingMixin(
ShadowMixin(MappableMixin(CatalogMemberMixin(Base)))
) {
protected tileset?: ObservableCesium3DTileset;
constructor(...args: any[]) {
super(...args);
runInAction(() => {
this.strata.set(Cesium3dTilesStratum.name, new Cesium3dTilesStratum());
});
}
get hasCesium3dTilesMixin() {
return true;
}
// Just a variable to save the original tileset.root.transform if it exists
private originalRootTransform: Matrix4 = Matrix4.IDENTITY.clone();
// An observable tracker for tileset.ready
isTilesetReady: boolean = false;
clippingPlanesOriginMatrix(): Matrix4 {
if (this.tileset && this.isTilesetReady) {
// clippingPlanesOriginMatrix is private.
// We need it to find the position where cesium centers the clipping plane for the tileset.
// See if we can find another way to get it.
if ((this.tileset as any).clippingPlanesOriginMatrix) {
return (this.tileset as any).clippingPlanesOriginMatrix.clone();
}
}
return Matrix4.IDENTITY.clone();
}
protected async forceLoadMapItems() {
try {
this.loadTileset();
if (this.tileset) {
const tileset = await this.tileset.readyPromise;
if (
tileset.extras !== undefined &&
tileset.extras.style !== undefined
) {
runInAction(() => {
this.strata.set(
CommonStrata.defaults,
createStratumInstance(Cesium3DTilesCatalogItemTraits, {
style: tileset.extras.style
})
);
});
}
}
} catch (e) {
throw TerriaError.from(e, "Failed to load 3d-tiles tileset");
}
}
private loadTileset() {
if (!isDefined(this.url) && !isDefined(this.ionAssetId)) {
throw `\`url\` and \`ionAssetId\` are not defined for ${getName(this)}`;
}
let resource = undefined;
if (isDefined(this.ionAssetId)) {
resource = this.createResourceFromIonId(
this.ionAssetId,
this.ionAccessToken,
this.ionServer
);
} else if (isDefined(this.url)) {
resource = this.createResourceFromUrl(
proxyCatalogItemUrl(this, this.url)
);
}
if (!isDefined(resource)) {
return;
}
const tileset = new ObservableCesium3DTileset({
...this.optionsObj,
url: resource
});
tileset._catalogItem = this;
runLater(
action(() => {
this.isTilesetReady = tileset.ready;
})
);
if (!tileset.destroyed) {
this.tileset = tileset;
}
// Save the original root tile transform and set its value to an identity
// matrix This lets us control the whole model transformation using just
// tileset.modelMatrix We later derive a tilset.modelMatrix by combining
// the root transform and transformation traits in mapItems.
tileset.readyPromise.then(
action(() => {
this.isTilesetReady = tileset.ready;
if (tileset.root !== undefined) {
this.originalRootTransform = tileset.root.transform.clone();
tileset.root.transform = Matrix4.IDENTITY.clone();
}
})
);
}
/**
* Computes a new model matrix by combining the given matrix with the
* origin, rotation & scale trait values
*/
private computeModelMatrixFromTransformationTraits(modelMatrix: Matrix4) {
let scale = Matrix4.getScale(modelMatrix, new Cartesian3());
let position = Matrix4.getTranslation(modelMatrix, new Cartesian3());
let orientation = Quaternion.fromRotationMatrix(
Matrix4.getMatrix3(modelMatrix, new Matrix3())
);
const { latitude, longitude, height } = this.origin;
if (latitude !== undefined && longitude !== undefined) {
const positionFromLatLng = Cartesian3.fromDegrees(
longitude,
latitude,
height
);
position.x = positionFromLatLng.x;
position.y = positionFromLatLng.y;
if (height !== undefined) {
position.z = positionFromLatLng.z;
}
}
const { heading, pitch, roll } = this.rotation;
if (heading !== undefined && pitch !== undefined && roll !== undefined) {
const hpr = HeadingPitchRoll.fromDegrees(heading, pitch, roll);
orientation = Transforms.headingPitchRollQuaternion(position, hpr);
}
if (this.scale !== undefined) {
scale = new Cartesian3(this.scale, this.scale, this.scale);
}
return Matrix4.fromTranslationQuaternionRotationScale(
position,
orientation,
scale
);
}
/**
* A computed that returns the result of transforming the original tileset
* root transform with the origin, rotation & scale traits for this catalog
* item
*/
get modelMatrix(): Matrix4 {
const modelMatrixFromTraits =
this.computeModelMatrixFromTransformationTraits(
this.originalRootTransform
);
return modelMatrixFromTraits;
}
get mapItems() {
if (this.isLoadingMapItems || !isDefined(this.tileset)) {
return [];
}
if (this.tileset.destroyed) {
this.loadMapItems(true);
}
this.tileset.style = toJS(this.cesiumTileStyle);
this.tileset.shadows = this.cesiumShadows;
this.tileset.show = this.show;
const key = this
.colorBlendMode as keyof typeof Cesium3DTileColorBlendMode;
const colorBlendMode = Cesium3DTileColorBlendMode[key];
if (colorBlendMode !== undefined)
this.tileset.colorBlendMode = colorBlendMode;
this.tileset.colorBlendAmount = this.colorBlendAmount;
// default is 16 (baseMaximumScreenSpaceError @ 2)
// we want to reduce to 8 for higher levels of quality
// the slider goes from [quality] 1 to 3 [performance]
// in 0.1 steps
const tilesetBaseSse =
this.options.maximumScreenSpaceError !== undefined
? this.options.maximumScreenSpaceError / 2.0
: 8;
this.tileset.maximumScreenSpaceError =
tilesetBaseSse * this.terria.baseMaximumScreenSpaceError;
this.tileset.modelMatrix = this.modelMatrix;
this.tileset.clippingPlanes = toJS(this.clippingPlaneCollection)!;
this.clippingMapItems.forEach((mapItem) => {
mapItem.show = this.show;
});
return [this.tileset, ...this.clippingMapItems];
}
get shortReport(): string | undefined {
if (this.terria.currentViewer.type === "Leaflet") {
return i18next.t("models.commonModelErrors.3dTypeIn2dMode", this);
}
return super.shortReport;
}
get optionsObj() {
const options: any = {};
if (isDefined(this.options)) {
Object.keys(OptionsTraits.traits).forEach((name) => {
options[name] = (<any>this.options)[name];
});
}
return options;
}
private createResourceFromUrl(url: Resource | string) {
if (!isDefined(url)) {
return;
}
let resource: Resource | undefined;
if (url instanceof Resource) {
resource = url;
} else {
resource = new Resource({ url });
}
return resource;
}
private async createResourceFromIonId(
ionAssetId: number | undefined,
ionAccessToken: string | undefined,
ionServer: string | undefined
) {
if (!isDefined(ionAssetId)) {
return;
}
let resource: IonResource | undefined = await IonResource.fromAssetId(
ionAssetId,
{
accessToken:
ionAccessToken || this.terria.configParameters.cesiumIonAccessToken,
server: ionServer
}
);
return resource;
}
get showExpressionFromFilters() {
if (!isDefined(this.filters)) {
return;
}
const terms = this.filters.map((filter) => {
if (!isDefined(filter.property)) {
return "";
}
// Escape single quotes, cast property value to number
const property =
"Number(${feature['" + filter.property.replace(/'/g, "\\'") + "']})";
const min =
isDefined(filter.minimumValue) &&
isDefined(filter.minimumShown) &&
filter.minimumShown > filter.minimumValue
? property + " >= " + filter.minimumShown
: "";
const max =
isDefined(filter.maximumValue) &&
isDefined(filter.maximumShown) &&
filter.maximumShown < filter.maximumValue
? property + " <= " + filter.maximumShown
: "";
return [min, max].filter((x) => x.length > 0).join(" && ");
});
const showExpression = terms.filter((x) => x.length > 0).join("&&");
if (showExpression.length > 0) {
return showExpression;
}
}
get cesiumTileStyle() {
if (
!isDefined(this.style) &&
(!isDefined(this.opacity) || this.opacity === 1) &&
!isDefined(this.showExpressionFromFilters)
) {
return;
}
const style = clone(toJS(this.style) || {});
const opacity = clone(toJS(this.opacity));
if (!isDefined(style.defines)) {
style.defines = { opacity };
} else {
style.defines = Object.assign(style.defines, { opacity });
}
// Rewrite color expression to also use the models opacity setting
if (!isDefined(style.color)) {
// Some tilesets (eg. point clouds) have a ${COLOR} variable which
// stores the current color of a feature, so if we have that, we should
// use it, and only change the opacity. We have to do it
// component-wise because `undefined` is mapped to a large float value
// (czm_infinity) in glsl in Cesium and so can only be compared with
// another float value.
//
// There is also a subtle bug which prevents us from using an
// expression in the alpha part of the rgba(). eg, using the
// expression '${COLOR}.a === undefined ? ${opacity} : ${COLOR}.a * ${opacity}'
// to generate an opacity value will cause Cesium to generate wrong
// translucency values making the tileset translucent even when the
// computed opacity is 1.0. It also makes the whole of the point cloud
// appear white when zoomed out to some distance. So for now, the only
// solution is to discard the opacity from the tileset and only use the
// value from the opacity trait.
style.color =
"(rgba(" +
"(${COLOR}.r === undefined ? 1 : ${COLOR}.r) * 255," +
"(${COLOR}.g === undefined ? 1 : ${COLOR}.g) * 255," +
"(${COLOR}.b === undefined ? 1 : ${COLOR}.b) * 255," +
"${opacity}" +
"))";
} else if (typeof style.color == "string") {
// Check if the color specified is just a css color
const cssColor = Color.fromCssColorString(style.color);
if (isDefined(cssColor)) {
style.color = `color('${style.color}', \${opacity})`;
}
}
if (isDefined(this.showExpressionFromFilters)) {
style.show = toJS(this.showExpressionFromFilters);
}
return new Cesium3DTileStyle(style);
}
/**
* This function should return null if allowFeaturePicking = false
* @param _screenPosition
* @param pickResult
*/
buildFeatureFromPickResult(
_screenPosition: Cartesian2 | undefined,
pickResult: any
) {
if (
this.allowFeaturePicking &&
(pickResult instanceof Cesium3DTileFeature ||
pickResult instanceof Cesium3DTilePointFeature)
) {
const properties: { [name: string]: unknown } = {};
pickResult.getPropertyNames().forEach((name) => {
properties[name] = pickResult.getProperty(name);
});
const result = new TerriaFeature({
properties
});
result._cesium3DTileFeature = pickResult;
return result;
}
}
/**
* Returns the name of properties to be used as an ID for this catalog item.
*
* The return value is an array of strings as the Id value could be formed
* by combining multiple properties. eg: ["latitudeprop", "longitudeprop"]
*/
getIdPropertiesForFeature(feature: Cesium3DTileFeature): string[] {
// If `featureIdProperties` is set return it, otherwise if the feature has
// a property named `id` return it.
if (this.featureIdProperties) return this.featureIdProperties.slice();
const propretyNamedId = feature
.getPropertyNames()
.find((name) => name.toLowerCase() === "id");
return propretyNamedId ? [propretyNamedId] : [];
}
/**
* Returns a selector that can be used for filtering or styling the given
* feature. For this to work, the feature should have a property called
* `id` or the catalog item should have the trait `featureIdProperties` defined.
*
* @returns Selector string or `undefined` when no unique selector can be constructed for the feature
*/
getSelectorForFeature(feature: Cesium3DTileFeature): string | undefined {
const idProperties = this.getIdPropertiesForFeature(feature).sort();
if (idProperties.length === 0) {
return;
}
const terms = idProperties.map(
(p: string) => `\${${p}} === ${JSON.stringify(feature.getProperty(p))}`
);
const selector = terms.join(" && ");
return selector ? selector : undefined;
}
setVisibilityForMatchingFeature(expression: string, visibility: boolean) {
if (expression) {
const style = this.style || {};
const show = normalizeShowExpression(style?.show);
show.conditions.unshift([expression, visibility]);
this.setTrait(CommonStrata.user, "style", { ...style, show });
}
}
/**
* Modifies the style traits to show/hide a 3d tile feature
*
*/
setFeatureVisibility(feature: Cesium3DTileFeature, visibility: boolean) {
const showExpr = this.getSelectorForFeature(feature);
if (showExpr) {
this.setVisibilityForMatchingFeature(showExpr, visibility);
}
}
/**
* Adds a new show expression to the styles trait.
*
* To ensure that we can add multiple show expressions, we first normalize
* the show expressions to a `show.conditions` array and then add the new
* expression. The new expression is added to the beginning of
* `show.conditions` so it will have the highest priority.
*
* @param newShowExpr The new show expression to add to the styles trait
*/
applyShowExpression(newShowExpr: { condition: string; show: boolean }) {
const style = this.style || {};
const show = normalizeShowExpression(style?.show);
show.conditions.unshift([newShowExpr.condition, newShowExpr.show]);
this.setTrait(CommonStrata.user, "style", { ...style, show });
}
/**
* Remove all show expressions that match the given condition.
*
* @param condition The condition string used to match the show expression.
*/
removeShowExpression(condition: string) {
const show = this.style?.show;
if (!isJsonObject(show)) return;
if (!isObservableArray(show.conditions)) return;
const conditions = show.conditions
.slice()
.filter((e) => e[0] !== condition);
this.setTrait(CommonStrata.user, "style", {
...this.style,
show: {
...show,
conditions
}
});
}
/**
* Adds a new color expression to the style traits.
*
* To ensure that we can add multiple color expressions, we first normalize the
* color expression to a `color.conditions` array. Then add the new expression to the
* beginning of the array. This gives the highest priority for the new color expression.
*
* @param newColorExpr The new color expression to add
*/
applyColorExpression(newColorExpr: { condition: string; value: string }) {
const style = this.style || {};
const color = normalizeColorExpression(style?.color);
color.conditions.unshift([newColorExpr.condition, newColorExpr.value]);
if (!color.conditions.find((c) => c[0] === "true")) {
color.conditions.push(["true", "color('#ffffff')"]); // ensure there is a default color
}
this.setTrait(CommonStrata.user, "style", {
...style,
color
} as JsonObject);
}
/**
* Removes all color expressions with the given condition from the style traits.
*/
removeColorExpression(condition: string) {
const color = this.style?.color;
if (!isJsonObject(color)) return;
if (!isObservableArray(color.conditions)) return;
const conditions = color.conditions
.slice()
.filter((e) => e[0] !== condition);
this.setTrait(CommonStrata.user, "style", {
...this.style,
color: {
...color,
conditions
}
});
}
/**
* The color to use for highlighting features in this catalog item.
*
*/
get highlightColor(): string {
return super.highlightColor || DEFAULT_HIGHLIGHT_COLOR;
}
}
return Cesium3dTilesMixin;
}
namespace Cesium3dTilesMixin {
export interface Instance
extends InstanceType<ReturnType<typeof Cesium3dTilesMixin>> {}
export function isMixedInto(model: any): model is Instance {
return model && model.hasCesium3dTilesMixin;
}
}
export default Cesium3dTilesMixin;
function normalizeShowExpression(show: any): {
conditions: [string, boolean][];
} {
let conditions;
if (Array.isArray(show?.conditions?.slice())) {
conditions = [...show.conditions];
} else if (typeof show === "string") {
conditions = [[show, true]];
} else {
conditions = [["true", true]];
}
return { ...show, conditions };
}
function normalizeColorExpression(expr: any): {
expression?: string;
conditions: [string, string][];
} {
const normalized: { expression?: string; conditions: [string, string][] } = {
conditions: []
};
if (typeof expr === "string") normalized.expression = expr;
if (isJsonObject(expr)) Object.assign(normalized, expr);
return normalized;
}