UNPKG

terriajs

Version:

Geospatial data visualization platform.

633 lines (572 loc) 21.2 kB
import i18next from "i18next"; import { action, computed, makeObservable, observable, toJS, untracked } from "mobx"; import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3"; import Cartographic from "terriajs-cesium/Source/Core/Cartographic"; import clone from "terriajs-cesium/Source/Core/clone"; import Color from "terriajs-cesium/Source/Core/Color"; import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid"; import HeadingPitchRoll from "terriajs-cesium/Source/Core/HeadingPitchRoll"; import Matrix3 from "terriajs-cesium/Source/Core/Matrix3"; import Matrix4 from "terriajs-cesium/Source/Core/Matrix4"; import Transforms from "terriajs-cesium/Source/Core/Transforms"; import CustomDataSource from "terriajs-cesium/Source/DataSources/CustomDataSource"; import DataSource from "terriajs-cesium/Source/DataSources/DataSource"; import ClippingPlane from "terriajs-cesium/Source/Scene/ClippingPlane"; import ClippingPlaneCollection from "terriajs-cesium/Source/Scene/ClippingPlaneCollection"; import AbstractConstructor from "../Core/AbstractConstructor"; import filterOutUndefined from "../Core/filterOutUndefined"; import runLater from "../Core/runLater"; import BoxDrawing from "../Models/BoxDrawing"; import Cesium from "../Models/Cesium"; import CommonStrata from "../Models/Definition/CommonStrata"; import Model from "../Models/Definition/Model"; import updateModelFromJson from "../Models/Definition/updateModelFromJson"; import { SelectableDimension, SelectableDimensionCheckboxGroup } from "../Models/SelectableDimensions/SelectableDimensions"; import Icon from "../Styled/Icon"; import ClippingPlanesTraits from "../Traits/TraitsClasses/ClippingPlanesTraits"; import HeadingPitchRollTraits from "../Traits/TraitsClasses/HeadingPitchRollTraits"; import LatLonHeightTraits from "../Traits/TraitsClasses/LatLonHeightTraits"; type BaseType = Model<ClippingPlanesTraits>; function ClippingMixin<T extends AbstractConstructor<BaseType>>(Base: T) { abstract class ClippingMixinBase extends Base { private _clippingBoxDrawing?: BoxDrawing; /** * Indicates whether we are currently zooming to the clipping box */ @observable _isZoomingToClippingBox: boolean = false; /** * A trigger for activating the clipping box repositioning UI for this item. */ @observable repositionClippingBoxTrigger = false; abstract clippingPlanesOriginMatrix(): Matrix4; private clippingPlaneModelMatrix: Matrix4 = Matrix4.IDENTITY.clone(); // Use a stable clipping plane collection. Replacing the collection on // change seems to crash Cesium, which could be related to: // https://github.com/CesiumGS/cesium/issues/6599 . @observable private _clippingPlaneCollection: ClippingPlaneCollection | undefined; constructor(...args: any[]) { super(...args); makeObservable(this); } get hasClippingMixin() { return true; } @computed get inverseClippingPlanesOriginMatrix(): Matrix4 { return Matrix4.inverse(this.clippingPlanesOriginMatrix(), new Matrix4()); } private get simpleClippingPlaneCollection() { if (!this.clippingPlanes) { return; } if (this.clippingPlanes.planes.length === 0) { return; } const { planes, enabled = true, unionClippingRegions = false, edgeColor, edgeWidth, modelMatrix } = this.clippingPlanes; const planesMapped = planes.map((plane: any) => { return new ClippingPlane( Cartesian3.fromArray(plane.normal || []), plane.distance ); }); let options = { planes: planesMapped, enabled, unionClippingRegions }; if (edgeColor && edgeColor.length > 0) { options = Object.assign(options, { edgeColor: Color.fromCssColorString(edgeColor) || Color.WHITE }); } if (edgeWidth && edgeWidth > 0) { options = Object.assign(options, { edgeWidth: edgeWidth }); } if (modelMatrix && modelMatrix.length > 0) { const array = clone(toJS(modelMatrix)); options = Object.assign(options, { modelMatrix: Matrix4.fromArray(array) || Matrix4.IDENTITY }); } const clippingPlaneCollection = this.getOrCreateClippingPlanesCollection(); return updateClippingPlanesCollection(clippingPlaneCollection, options); } @computed get clippingBoxPlaneCollection() { if (!this.clippingBox.enableFeature) { return; } const clippingPlaneCollection = this.getOrCreateClippingPlanesCollection(); if (!this.clippingBox.clipModel) { clippingPlaneCollection.enabled = false; return clippingPlaneCollection; } const clipDirection = this.clippingBox.clipDirection === "inside" ? -1 : 1; const planes = BoxDrawing.localSidePlanes.map((plane) => { return new ClippingPlane(plane.normal, plane.distance * clipDirection); }); untracked(() => { Matrix4.multiply( this.inverseClippingPlanesOriginMatrix, this.clippingBoxTransform, this.clippingPlaneModelMatrix ); }); updateClippingPlanesCollection(clippingPlaneCollection, { modelMatrix: this.clippingPlaneModelMatrix, planes, unionClippingRegions: this.clippingBox.clipDirection === "outside", enabled: this.clippingBox.clipModel }); return clippingPlaneCollection; } private getOrCreateClippingPlanesCollection(): ClippingPlaneCollection { if (this._clippingPlaneCollection) { return this._clippingPlaneCollection; } this._clippingPlaneCollection = new ClippingPlaneCollection(); // Unset our reference if the collection gets destroyed. // This could happen for example when switching to 2D mode and back to 3D. const originalDestroy = this._clippingPlaneCollection.destroy; this._clippingPlaneCollection.destroy = action(() => { originalDestroy.apply(this._clippingPlaneCollection); this._clippingPlaneCollection = undefined; }); return this._clippingPlaneCollection; } @computed get clippingPlaneCollection(): ClippingPlaneCollection | undefined { return ( this.simpleClippingPlaneCollection ?? this.clippingBoxPlaneCollection ); } @computed get clippingMapItems(): CustomDataSource[] { return filterOutUndefined([this.clippingBoxDrawing?.dataSource]); } @computed private get clippingBoxDimensions(): Cartesian3 { const dimensions = new Cartesian3( this.clippingBox.dimensions.length ?? 100, this.clippingBox.dimensions.width ?? 100, this.clippingBox.dimensions.height ?? 100 ); return dimensions; } @computed private get clippingBoxHpr(): HeadingPitchRoll | undefined { const { heading, pitch, roll } = this.clippingBox.rotation; return heading !== undefined && pitch !== undefined && roll !== undefined ? HeadingPitchRoll.fromDegrees(heading, pitch, roll) : undefined; } @computed private get clippingBoxPosition(): Cartesian3 { const dimensions = this.clippingBoxDimensions; const clippingPlanesOriginMatrix = this.clippingPlanesOriginMatrix(); let position = LatLonHeightTraits.toCartesian(this.clippingBox.position); if (!position) { // Use clipping plane origin as position but height set to 0 so that the box is grounded. const cartographic = Cartographic.fromCartesian( Matrix4.getTranslation(clippingPlanesOriginMatrix, new Cartesian3()) ); // If the translation is at the center of the ellipsoid then this cartographic could be undefined. // Although it is not reflected in the typescript type. if (cartographic) { cartographic.height = dimensions.z / 2; position = Ellipsoid.WGS84.cartographicToCartesian( cartographic, new Cartesian3() ); } } // Nothing we can do - assign to zero position ??= Cartesian3.ZERO.clone(); return position; } @computed private get clippingBoxTransform(): Matrix4 { const hpr = this.clippingBoxHpr; const position = this.clippingBoxPosition; const dimensions = this.clippingBoxDimensions; const boxTransform = Matrix4.multiply( hpr ? Matrix4.fromRotationTranslation( Matrix3.fromHeadingPitchRoll(hpr), position ) : Transforms.eastNorthUpToFixedFrame(position), Matrix4.fromScale(dimensions, new Matrix4()), new Matrix4() ); return boxTransform; } @computed get clippingBoxDrawing(): BoxDrawing | undefined { const options = this.clippingBox; const cesium = this.terria.cesium; if ( !cesium || !options.enableFeature || !options.clipModel || !options.showClippingBox ) { if (this._clippingBoxDrawing) { this._clippingBoxDrawing = undefined; } return; } const boxTransform = this.clippingBoxTransform; Matrix4.multiply( this.inverseClippingPlanesOriginMatrix, boxTransform, this.clippingPlaneModelMatrix ); if (this._clippingBoxDrawing) { this._clippingBoxDrawing.setTransform(boxTransform); this._clippingBoxDrawing.keepBoxAboveGround = this.clippingBox.keepBoxAboveGround; } else { this._clippingBoxDrawing = BoxDrawing.fromTransform( cesium, boxTransform, { keepBoxAboveGround: this.clippingBox.keepBoxAboveGround, onChange: action(({ modelMatrix, isFinished }) => { Matrix4.multiply( this.inverseClippingPlanesOriginMatrix, modelMatrix, this.clippingPlaneModelMatrix ); if (isFinished) { const position = Matrix4.getTranslation( modelMatrix, new Cartesian3() ); LatLonHeightTraits.setFromCartesian( this.clippingBox.position, CommonStrata.user, position ); const dimensions = Matrix4.getScale( modelMatrix, new Cartesian3() ); updateModelFromJson( this.clippingBox.dimensions, CommonStrata.user, { length: dimensions.x, width: dimensions.y, height: dimensions.z } ).logError("Failed to update clipping box dimensions"); const rotationMatrix = Matrix3.getRotation( Matrix4.getMatrix3(modelMatrix, new Matrix3()), new Matrix3() ); HeadingPitchRollTraits.setFromRotationMatrix( this.clippingBox.rotation, CommonStrata.user, rotationMatrix ); } }) } ); } return this._clippingBoxDrawing; } @computed private get isClippingBoxPlaced() { const { longitude, latitude, height } = this.clippingBox.position; return ( longitude !== undefined && latitude !== undefined && height !== undefined ); } @computed get clippingDimensions(): SelectableDimension[] { if (!this.clippingBox.enableFeature) { return []; } const checkboxGroupInputs: SelectableDimensionCheckboxGroup["selectableDimensions"] = this.repositionClippingBoxTrigger ? [ /* don't show options when repositioning clipping box */ ] : [ { // Checkbox to show/hide clipping box id: "show-clip-editor-ui", type: "checkbox", selectedId: this.clippingBox.showClippingBox ? "true" : "false", disable: this.clippingBox.clipModel === false, options: [ { id: "true", name: i18next.t("models.clippingBox.showClippingBox") }, { id: "false", name: i18next.t("models.clippingBox.showClippingBox") } ], setDimensionValue: (stratumId, value) => { this.clippingBox.setTrait( stratumId, "showClippingBox", value === "true" ); } }, { // Checkbox to clamp/unclamp box to ground id: "clamp-box-to-ground", type: "checkbox", selectedId: this.clippingBox.keepBoxAboveGround ? "true" : "false", disable: this.clippingBox.clipModel === false || this.clippingBox.showClippingBox === false, options: [ { id: "true", name: i18next.t("models.clippingBox.keepBoxAboveGround") }, { id: "false", name: i18next.t("models.clippingBox.keepBoxAboveGround") } ], setDimensionValue: (stratumId, value) => { this.clippingBox.setTrait( stratumId, "keepBoxAboveGround", value === "true" ); } }, { // Dropdown to change the clipping direction id: "clip-direction", name: i18next.t("models.clippingBox.clipDirection.name"), type: "select", selectedId: this.clippingBox.clipDirection, disable: this.clippingBox.clipModel === false || this.clippingBox.showClippingBox === false, options: [ { id: "inside", name: i18next.t( "models.clippingBox.clipDirection.options.inside" ) }, { id: "outside", name: i18next.t( "models.clippingBox.clipDirection.options.outside" ) } ], setDimensionValue: (stratumId, value) => { this.clippingBox.setTrait(stratumId, "clipDirection", value); } }, ...this.repositioningAndZoomingDimensions ]; return [ { // Checkbox group that also enables/disables the clipping behaviour altogether type: "checkbox-group", id: "clipping-box", selectedId: this.clippingBox.clipModel ? "true" : "false", options: [ { id: "true", name: `${i18next.t("models.clippingBox.clipModel")}` }, { id: "false", name: i18next.t("models.clippingBox.clipModel") } ], emptyText: "Click on map to position clipping box", setDimensionValue: action((stratumId, value) => { const clipModel = value === "true"; this.clippingBox.setTrait(stratumId, "clipModel", clipModel); // Trigger clipping box repositioning UI if the feature is enabled // and a box position is not already set. const triggerClippingBoxRepositioning = !this.isClippingBoxPlaced; if (triggerClippingBoxRepositioning) { this.repositionClippingBoxTrigger = true; } }), selectableDimensions: checkboxGroupInputs } ]; } /** * Returns controls for repositioning and zooming to clipping box. Note * that these are temporary features that are enabled through a feature * flag. It will get removed once we switch to a new design for a global * clipping box. */ @computed private get repositioningAndZoomingDimensions(): SelectableDimensionCheckboxGroup["selectableDimensions"] { const repositioningAndZoomingInputs: SelectableDimensionCheckboxGroup["selectableDimensions"] = [ { // Button to zoom to clipping box id: "zoom-to-clipping-box-button", type: "button", value: "Zoom to&nbsp;&nbsp;&nbsp;", icon: this._isZoomingToClippingBox ? "spinner" : Icon.GLYPHS.search, disable: this.clippingBox.clipModel === false || this.clippingBoxDrawing === undefined, setDimensionValue: () => { if (!this._isZoomingToClippingBox) { this._zoomToClippingBox(); } } }, { id: "reposition-clipping-box", type: "button", value: "Reposition", icon: Icon.GLYPHS.geolocation, disable: this.clippingBox.clipModel === false || this.clippingBoxDrawing === undefined, setDimensionValue: action(() => { // Disable repositioning tool if already active if (this.repositionClippingBoxTrigger) { this.repositionClippingBoxTrigger = false; return; } // Enable repositioning tool, but first disable it for other workbench items this.terria.workbench.items.forEach((it) => { if (ClippingMixin.isMixedInto(it)) { it.repositionClippingBoxTrigger = false; } }); this.repositionClippingBoxTrigger = true; }) } ]; return repositioningAndZoomingInputs; } /** * Initiates zooming to the clipping box if it is rendered on the map. * Times out in 3 seconds if zooming is not possible. * * Also sets the observable variable `_isZoomingToClippingBox` to indicate the * zooming status. */ _zoomToClippingBox() { const dataSource = this.clippingBoxDrawing?.dataSource; const cesium = this.terria.cesium; if (!dataSource || !cesium) { return; } this._isZoomingToClippingBox = true; zoomToDataSourceWithTimeout( dataSource, 3000, // timeout after 3 seconds if we cannot zoom for some reason cesium ) .catch(() => { /* ignore errors */ }) .finally( action(() => { this._isZoomingToClippingBox = false; }) ); } } return ClippingMixinBase; } type ClippingPlaneCollectionOptions = NonNullable< ConstructorParameters<typeof ClippingPlaneCollection>[0] >; /** * Update a clipping plane collection instance with the given options */ function updateClippingPlanesCollection( clippingPlaneCollection: ClippingPlaneCollection, options: ClippingPlaneCollectionOptions ): ClippingPlaneCollection { const { planes, ...otherOptions } = options; Object.assign(clippingPlaneCollection, otherOptions); if (planes) { clippingPlaneCollection.removeAll(); planes.forEach((plane) => clippingPlaneCollection.add(plane)); } return clippingPlaneCollection; } /** * Zooms to the given dataSource and returns a promise that fullfills when the * zoom action is complete. If the dataSource has not been rendered on the map, * we wait for `timeoutMilliseconds` before rejecting the promise. */ function zoomToDataSourceWithTimeout( dataSource: DataSource, timeoutMilliseconds: number, cesium: Cesium ): Promise<void> { // DataSources rendered on the map const renderedDataSources = cesium.dataSources; if (renderedDataSources.contains(dataSource)) { return cesium.doZoomTo(dataSource); } else { // Create a promise that waits for the dataSource to be added to map or // timeout to complete whichever happens first return new Promise<void>((resolve, reject) => { const removeListener = renderedDataSources.dataSourceAdded.addEventListener((_, added) => { if (added === dataSource) { removeListener(); resolve(cesium.doZoomTo(dataSource)); } }); runLater(removeListener, timeoutMilliseconds).then(reject); }); } } namespace ClippingMixin { export interface Instance extends InstanceType< ReturnType<typeof ClippingMixin> > {} export function isMixedInto(model: any): model is Instance { return model?.hasClippingMixin === true; } } export default ClippingMixin;