UNPKG

terriajs

Version:

Geospatial data visualization platform.

596 lines (532 loc) 19.9 kB
import i18next from "i18next"; import { action, computed, isObservableArray, makeObservable, observable, override, runInAction, toJS } from "mobx"; import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2"; import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3"; 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 AbstractConstructor from "../Core/AbstractConstructor"; import { JsonObject, isJsonObject } from "../Core/Json"; import TerriaError from "../Core/TerriaError"; import isDefined from "../Core/isDefined"; import runLater from "../Core/runLater"; import proxyCatalogItemUrl from "../Models/Catalog/proxyCatalogItemUrl"; import CommonStrata from "../Models/Definition/CommonStrata"; import LoadableStratum from "../Models/Definition/LoadableStratum"; import Model, { BaseModel } from "../Models/Definition/Model"; import StratumOrder from "../Models/Definition/StratumOrder"; import createStratumInstance from "../Models/Definition/createStratumInstance"; import TerriaFeature from "../Models/Feature/Feature"; import { SelectableDimension } from "../Models/SelectableDimensions/SelectableDimensions"; import Cesium3DTilesCatalogItemTraits from "../Traits/TraitsClasses/Cesium3DTilesCatalogItemTraits"; import { default as Cesium3DTilesTraits, default as Cesium3dTilesTraits, OptionsTraits } from "../Traits/TraitsClasses/Cesium3dTilesTraits"; import CatalogMemberMixin, { getName } from "./CatalogMemberMixin"; import Cesium3dTilesStyleMixin from "./Cesium3dTilesStyleMixin"; import ClippingMixin from "./ClippingMixin"; import MappableMixin from "./MappableMixin"; import ShadowMixin from "./ShadowMixin"; interface Cesium3DTilesCatalogItemIface extends InstanceType< ReturnType<typeof Cesium3dTilesMixin> > {} export class ObservableCesium3DTileset extends Cesium3DTileset { _catalogItem?: Cesium3DTilesCatalogItemIface; @observable destroyed = false; constructor(...args: ConstructorParameters<typeof Cesium3DTileset>) { super(...args); makeObservable(this); } destroy(): void { 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; }); }); } } class Cesium3dTilesLoadableStratum extends LoadableStratum( Cesium3DTilesTraits ) { static stratumName = "cesium3dTilesLoadableStratum"; duplicateLoadableStratum(newModel: BaseModel): this { return new Cesium3dTilesLoadableStratum(newModel) as this; } get supportsReordering() { // Enable reordering in workbench if draping is enabled return this.drapeImagery; } } StratumOrder.addLoadStratum(Cesium3dTilesLoadableStratum.stratumName); type BaseType = Model<Cesium3dTilesTraits>; function Cesium3dTilesMixin<T extends AbstractConstructor<BaseType>>(Base: T) { abstract class Cesium3dTilesMixin extends Cesium3dTilesStyleMixin( ClippingMixin(ShadowMixin(MappableMixin(CatalogMemberMixin(Base)))) ) { protected tileset?: ObservableCesium3DTileset; constructor(...args: any[]) { super(...args); makeObservable(this); this.strata.set( Cesium3dTilesLoadableStratum.stratumName, new Cesium3dTilesLoadableStratum(this) ); } get hasCesium3dTilesMixin() { return true; } // Just a variable to save the original tileset.root.transform if it exists @observable private originalRootTransform: Matrix4 = Matrix4.IDENTITY.clone(); clippingPlanesOriginMatrix(): Matrix4 { if (this.tileset) { // 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 { await this.loadTileset(); if (this.tileset) { const tileset = this.tileset; 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; } // 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. return Promise.resolve(resource).then((resource) => { if (resource === undefined) return; const tilesetPromise = Cesium3DTileset.fromUrl(resource, { ...this.optionsObj }); return tilesetPromise.then((tileset) => { // Hackily turn the Cesium3DTileset into an ObservableCesium3DTileset const anyTileset: any = tileset; anyTileset._catalogItem = this; anyTileset.destroyed = tileset.isDestroyed(); const superDestroy = anyTileset.destroy; anyTileset.destroy = function () { superDestroy.call(this); // 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; }); }); }; makeObservable(anyTileset, { destroyed: observable }); const observableTileset: ObservableCesium3DTileset = anyTileset; runInAction(() => { observableTileset._catalogItem = this; if (!observableTileset.destroyed) { this.tileset = observableTileset; if (observableTileset.root !== undefined) { this.originalRootTransform = observableTileset.root.transform.clone(); observableTileset.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()); const position = Matrix4.getTranslation(modelMatrix, new Cartesian3()); let orientation = Quaternion.fromRotationMatrix( Matrix4.getRotation(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 */ @computed get modelMatrix(): Matrix4 { const modelMatrixFromTraits = this.computeModelMatrixFromTransformationTraits( this.originalRootTransform ); return modelMatrixFromTraits; } @computed 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; if (this.lightColor) this.tileset.lightColor = Cartesian3.fromArray(this.lightColor.slice()); // 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 = this.clippingPlaneCollection!; this.clippingMapItems.forEach((mapItem) => { mapItem.show = this.show; }); return [this.tileset, ...this.clippingMapItems]; } @override get shortReport(): string | undefined { if (this.terria.currentViewer.type === "Leaflet") { return i18next.t("models.commonModelErrors.3dTypeIn2dMode", this); } return super.shortReport; } @computed get optionsObj() { const options: any = {}; if (isDefined(this.options)) { Object.keys(OptionsTraits.traits).forEach((name) => { options[name] = (this.options as any)[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; } const resource: IonResource | undefined = await IonResource.fromAssetId( ionAssetId, { accessToken: ionAccessToken || this.terria.configParameters.cesiumIonAccessToken, server: ionServer } ); return resource; } /** * 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.getPropertyIds().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 .getPropertyIds() .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 * */ @action 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 */ @action 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. */ @action 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 */ @action 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. */ @action 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 } }); } @override get selectableDimensions(): SelectableDimension[] { return [ ...super.selectableDimensions, ...super.shadowDimensions, ...super.clippingDimensions ]; } } return Cesium3dTilesMixin; } namespace Cesium3dTilesMixin { export interface Instance extends InstanceType< ReturnType<typeof Cesium3dTilesMixin> > {} export function isMixedInto(model: any): model is Instance { return model && model.hasCesium3dTilesMixin; } } 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; } export default Cesium3dTilesMixin;