UNPKG

terriajs

Version:

Geospatial data visualization platform.

1,583 lines (1,398 loc) 59.8 kB
import throttle from "lodash-es/throttle"; import { makeObservable, observable, onBecomeObserved, onBecomeUnobserved } from "mobx"; import ArcType from "terriajs-cesium/Source/Core/ArcType"; import BoundingSphere from "terriajs-cesium/Source/Core/BoundingSphere"; import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2"; import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3"; import Cartographic from "terriajs-cesium/Source/Core/Cartographic"; import Color from "terriajs-cesium/Source/Core/Color"; import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid"; import EllipsoidTerrainProvider from "terriajs-cesium/Source/Core/EllipsoidTerrainProvider"; import HeadingPitchRoll from "terriajs-cesium/Source/Core/HeadingPitchRoll"; import IntersectionTests from "terriajs-cesium/Source/Core/IntersectionTests"; import JulianDate from "terriajs-cesium/Source/Core/JulianDate"; import KeyboardEventModifier from "terriajs-cesium/Source/Core/KeyboardEventModifier"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; import Matrix3 from "terriajs-cesium/Source/Core/Matrix3"; import Matrix4 from "terriajs-cesium/Source/Core/Matrix4"; import Plane from "terriajs-cesium/Source/Core/Plane"; import Quaternion from "terriajs-cesium/Source/Core/Quaternion"; import Ray from "terriajs-cesium/Source/Core/Ray"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import sampleTerrainMostDetailed from "terriajs-cesium/Source/Core/sampleTerrainMostDetailed"; import ScreenSpaceEventHandler from "terriajs-cesium/Source/Core/ScreenSpaceEventHandler"; import ScreenSpaceEventType from "terriajs-cesium/Source/Core/ScreenSpaceEventType"; import TranslationRotationScale from "terriajs-cesium/Source/Core/TranslationRotationScale"; import CallbackProperty from "terriajs-cesium/Source/DataSources/CallbackProperty"; import ColorMaterialProperty from "terriajs-cesium/Source/DataSources/ColorMaterialProperty"; import CustomDataSource from "terriajs-cesium/Source/DataSources/CustomDataSource"; import Entity from "terriajs-cesium/Source/DataSources/Entity"; import PlaneGraphics from "terriajs-cesium/Source/DataSources/PlaneGraphics"; import PolylineDashMaterialProperty from "terriajs-cesium/Source/DataSources/PolylineDashMaterialProperty"; import PositionProperty from "terriajs-cesium/Source/DataSources/PositionProperty"; import Axis from "terriajs-cesium/Source/Scene/Axis"; import Scene from "terriajs-cesium/Source/Scene/Scene"; import isDefined from "../Core/isDefined"; import { CustomCursorType, getCustomCssCursor } from "./BoxDrawing/cursors"; import Cesium from "./Cesium"; export type ChangeEvent = { isFinished: boolean; modelMatrix: Matrix4; }; export type ChangeEventHandler = (event: ChangeEvent) => void; type MouseClick = { position: Cartesian2 }; type MouseMove = { startPosition: Cartesian2; endPosition: Cartesian2 }; /** * An object that responds to box parameter updates. */ type Updatable = { update: () => void }; /** * A user interactable object */ type Interactable = { onMouseOver: (mouseMove: MouseMove) => void; onMouseOut: (mouseMove: MouseMove) => void; onPick: (click: MouseClick) => void; onRelease: () => void; onDrag: (mouseMove: MouseMove) => void; }; /** * An object that responds to camera changes */ type CameraAware = { updateOnCameraChange: () => void; }; /** * A box side */ type Side = Entity & Updatable & Interactable & CameraAware & { isSide: true; plane: PlaneGraphics; isFacingCamera: boolean; highlight: () => void; unHighlight: () => void; }; /** * A box edge */ type Edge = Entity & Updatable & Interactable; /** * Style for a box side */ type SideStyle = { fillColor: Color; outlineColor: Color; highlightFillColor: Color; highlightOutlineColor: Color; }; /** * A scale grip point */ type ScalePoint = Entity & Updatable & Interactable & CameraAware & { position: PositionProperty; oppositeScalePoint: ScalePoint; axisLine: Entity; }; /** * Style for a scale point */ type ScalePointStyle = { cornerPointColor: Color; facePointColor: Color; dimPointColor: Color; }; /** * The current interaction state of the box */ type InteractionState = | { is: "none" } | { is: "picked"; entity: Entity & Interactable; beforePickState: { isFeaturePickingPaused: boolean; enableInputs: boolean; }; } | { is: "hovering"; entity: Entity & Interactable }; export type BoxDrawingChangeParams = { /** * The modelMatrix of the box */ modelMatrix: Matrix4; /** * The translation rotation scale of the box */ translationRotationScale: TranslationRotationScale; /** * True if the change is finished or false if it is ongoing */ isFinished: boolean; }; type BoxDrawingOptions = { /** * When true, prevents the box from going underground. Note that we only use * the center of the bottom face to detect if the box is underground. For * large boxes, this center point can be above ground while the corners are * underground. */ keepBoxAboveGround?: boolean; /** * A callback method to call when box parameters change. */ onChange?: (params: BoxDrawingChangeParams) => void; /** * When set to `false`, do not draw the scale grips on the box faces, used for non-uniform scaling. * Defaults to `true`, i.e draws the non-uniform scaling grips. */ drawNonUniformScaleGrips?: boolean; /** * When set to `true`, disable dragging of top and bottom planes to change the box height. * Defaults to `false`, i.e top and bottom sides are draggable and dragging them changes the box height. */ disableVerticalMovement?: boolean; }; // The 6 box sides defined as planes in local coordinate space. const SIDE_PLANES: Plane[] = [ new Plane(new Cartesian3(0, 0, 1), 0.5), new Plane(new Cartesian3(0, 0, -1), 0.5), new Plane(new Cartesian3(0, 1, 0), 0.5), new Plane(new Cartesian3(0, -1, 0), 0.5), new Plane(new Cartesian3(1, 0, 0), 0.5), new Plane(new Cartesian3(-1, 0, 0), 0.5) ]; const CORNER_POINT_VECTORS = [ new Cartesian3(0.5, 0.5, 0.5), new Cartesian3(0.5, -0.5, 0.5), new Cartesian3(-0.5, -0.5, 0.5), new Cartesian3(-0.5, 0.5, 0.5) ]; const FACE_POINT_VECTORS = [ new Cartesian3(0.5, 0.0, 0.0), new Cartesian3(0.0, 0.5, 0.0), new Cartesian3(0.0, 0.0, 0.5) ]; /** * Checks whether the given entity is updatable (i.e repsonds to box parameter changes). */ function isUpdatable(entity: Entity): entity is Entity & Updatable { return typeof (entity as any).update === "function"; } /** * Checks whether the given entity is interactable. */ function isInteractable(entity: Entity): entity is Entity & Interactable { return ( typeof (entity as any).onPick === "function" && typeof (entity as any).onRelease === "function" && typeof (entity as any).onMouseOver === "function" && typeof (entity as any).onMouseOut === "function" ); } export function isSideEntity(entity: Entity): entity is Side { return (entity as any).isSide; } export default class BoxDrawing { static localSidePlanes = SIDE_PLANES; // Observable because we want to start/stop interactions when the datasource // gets used/removed. @observable public dataSource: CustomDataSource; private _keepBoxAboveGround = false; private drawNonUniformScaleGrips: boolean; public disableVerticalMovement: boolean; public keepHeightSteadyWhenMovingLaterally = true; public onChange?: (params: BoxDrawingChangeParams) => void; // An external transform to convert the box in local coordinates to world coordinates private readonly worldTransform: Matrix4 = Matrix4.IDENTITY.clone(); // The translation, rotation & scale (i.e position, orientation, dimensions) of the box private readonly trs = new TranslationRotationScale(); // A matrix representation of trs private readonly modelMatrix: Matrix4 = Matrix4.IDENTITY.clone(); private scene: Scene; // A disposer function to destroy all event handlers private interactionsDisposer?: () => void; // Sides of the box defined as cesium entities with additional properties private readonly sides: Side[] = []; // Scale points on the box defined as cesium entities with additional properties private readonly scalePoints: ScalePoint[] = []; private readonly edges: Edge[] = []; private isHeightUpdateInProgress: boolean = false; private terrainHeightEstimate: number = 0; // Flag to turn scaling interaction on or off private _enableScaling = true; // Flag to turn rotation interaction on or off private _enableRotation = true; /** * A private constructor. Use {@link BoxDrawing.fromTransform} or {@link BoxDrawing.fromTranslationRotationScale} to create instances. */ private constructor( readonly cesium: Cesium, transform: Matrix4, options: BoxDrawingOptions ) { makeObservable(this); this.scene = cesium.scene; this.keepBoxAboveGround = options.keepBoxAboveGround ?? false; this.drawNonUniformScaleGrips = options.drawNonUniformScaleGrips ?? true; this.disableVerticalMovement = options.disableVerticalMovement ?? false; this.onChange = options.onChange; this.dataSource = new Proxy(new CustomDataSource(), { set: (target, prop, value) => { if (prop === "show") { if (value === true) { this.startInteractions(); } else { this.stopInteractions(); } } return Reflect.set(target, prop, value); } }); this.setTransform(transform); this.drawBox(); this.setBoxAboveGround(); onBecomeObserved(this, "dataSource", () => this.startInteractions()); onBecomeUnobserved(this, "dataSource", () => this.stopInteractions()); } /** * Construct `BoxDrawing` from a transformation matrix. * * @param cesium - A Cesium instance * @param transform - A transformation that positions the box in the world. * @param options - {@link BoxDrawingOptions} * @returns A `BoxDrawing` instance */ static fromTransform( cesium: Cesium, transform: Matrix4, options?: BoxDrawingOptions ): BoxDrawing { return new BoxDrawing(cesium, transform, options ?? {}); } /** * Construct `BoxDrawing` from a {@link TranslationRotationScale} object. * * @param cesium - A Cesium instance * @param trs - Translation, rotation and scale of the object. * @param options - {@link BoxDrawingOptions} * @returns A `BoxDrawing` instance */ static fromTranslationRotationScale( cesium: Cesium, trs: TranslationRotationScale, options?: BoxDrawingOptions ): BoxDrawing { const boxDrawing = new BoxDrawing( cesium, Matrix4.fromTranslationRotationScale(trs), options ?? {} ); return boxDrawing; } public setTranslationRotationScale(trs: TranslationRotationScale): void { Cartesian3.clone(trs.translation, this.trs.translation); Quaternion.clone(trs.rotation, this.trs.rotation); Cartesian3.clone(trs.scale, this.trs.scale); this.updateBox(); } /** * A method to udpate the world transform. */ public setTransform(transform: Matrix4): void { Matrix4.clone(transform, this.worldTransform); Matrix4.getTranslation(this.worldTransform, this.trs.translation); Matrix4.getScale(this.worldTransform, this.trs.scale); Quaternion.fromRotationMatrix( Matrix3.getRotation( Matrix4.getMatrix3(this.worldTransform, new Matrix3()), new Matrix3() ), this.trs.rotation ); this.updateBox(); } get keepBoxAboveGround() { return this._keepBoxAboveGround; } set keepBoxAboveGround(value: boolean) { if (this._keepBoxAboveGround === value) { return; } this._keepBoxAboveGround = value; this.setBoxAboveGround().then(() => { this.onChange?.({ modelMatrix: this.modelMatrix, translationRotationScale: this.trs, isFinished: true }); }); } get enableScaling() { return this._enableScaling; } set enableScaling(enable: boolean) { this._enableScaling = enable; this.scalePoints.forEach((scalePoint) => (scalePoint.show = enable)); } get enableRotation() { return this._enableRotation; } set enableRotation(enable: boolean) { this._enableRotation = enable; this.edges.forEach((edge) => (edge.show = enable)); } /** * Moves the box by the provided moveStep with optional clamping applied so that the * box does not go underground. * * @param moveStep The amount by which to move the box */ private moveBoxWithClamping = (() => { const scratchNewPosition = new Cartesian3(); const scratchCartographic = new Cartographic(); return (moveStep: Cartesian3) => { const nextPosition = Cartesian3.add( this.trs.translation, moveStep, scratchNewPosition ); if (this.keepBoxAboveGround) { const cartographic = Cartographic.fromCartesian( nextPosition, undefined, scratchCartographic ); const boxBottomHeight = cartographic.height - this.trs.scale.z / 2; const floorHeight: number = this.terrainHeightEstimate; if (boxBottomHeight < floorHeight) { cartographic.height += floorHeight - boxBottomHeight; Cartographic.toCartesian(cartographic, undefined, nextPosition); } } Cartesian3.clone(nextPosition, this.trs.translation); }; })(); /** * Set the box position */ setPosition(position: Cartesian3): void { const moveStep = Cartesian3.subtract( position, this.trs.translation, new Cartesian3() ); this.moveBoxWithClamping(moveStep); this.updateBox(); } /** * Update the terrain height estimate at the current box position. * * If the terrainProvider is the `EllipsoidTerrainProvider` this simply sets * the estimate to 0. Otherwise we request the terrain provider for the most * detailed height estimate. To avoid concurrent attempts we skip the call * if any other request is active. `forceUpdate` can be used to force an * update even when an earlier request is active. */ private updateTerrainHeightEstimate = (() => { const scratchBoxCenter = new Cartographic(); const scratchFloor = new Cartographic(); return async (forceUpdate = false) => { if (!this.keepBoxAboveGround) { return; } if (this.isHeightUpdateInProgress && !forceUpdate) { return; } const terrainProvider = this.scene.terrainProvider; if (terrainProvider instanceof EllipsoidTerrainProvider) { this.terrainHeightEstimate = 0; return; } const boxCenter = this.trs.translation && Cartographic.fromCartesian( this.trs.translation, undefined, scratchBoxCenter ); if (!boxCenter) { this.terrainHeightEstimate = 0; return; } this.isHeightUpdateInProgress = true; try { const [floor] = await sampleTerrainMostDetailed(terrainProvider, [ Cartographic.clone(boxCenter, scratchFloor) ]); if (floor.height !== undefined) { this.terrainHeightEstimate = floor.height; } } finally { this.isHeightUpdateInProgress = false; } }; })(); async setBoxAboveGround(): Promise<void> { if (!this.keepBoxAboveGround) { return; } // Get the latest terrain height estimate and update the box position return this.updateTerrainHeightEstimate(true).then(() => { this.moveBoxWithClamping(Cartesian3.ZERO); this.updateBox(); }); } /** * Sets up event handlers if not already done. */ private startInteractions() { if (this.interactionsDisposer) { // already started return; } let eventHandler: { destroy: () => void } | undefined; // Start event handling if not already started const startMapInteractions = () => { if (!eventHandler) { eventHandler = this.createEventHandler(); } }; // Stop event handling const stopMapInteractions = () => { eventHandler?.destroy(); eventHandler = undefined; }; // Watch camera changes to update entities and to enable/disable // interactions when the box comes in and out of view. const onCameraChange = () => { this.updateEntitiesOnOrientationChange(); }; // Camera event disposer const disposeCameraEvent = this.scene.camera.changed.addEventListener(onCameraChange); // Disposer for map interactions & camera events this.interactionsDisposer = () => { stopMapInteractions(); disposeCameraEvent(); this.interactionsDisposer = undefined; }; startMapInteractions(); // Call once to initialize onCameraChange(); } private stopInteractions() { this.interactionsDisposer?.(); } /** * Updates all box parameters from changes to the localTransform. */ private updateBox() { Matrix4.fromTranslationRotationScale(this.trs, this.modelMatrix); this.dataSource.entities.values.forEach((entity) => { if (isUpdatable(entity)) entity.update(); }); } private updateEntitiesOnOrientationChange() { this.sides.forEach((side) => side.updateOnCameraChange()); this.scalePoints.forEach((scalePoint) => scalePoint.updateOnCameraChange()); } /** * Returns true if the box is in camera view. */ private isBoxInCameraView() { // This method is unused until we figure out a better way to implement it. // The view rectangle is a rectangle on the ellipsoid so // intersecting with it wont work correctly for oblique camera angles. // We might have to intersect with the camera frustum volume // like here: https://stackoverflow.com/questions/58207413/how-to-check-if-a-cesium-entity-is-visible-occluded const viewRectangle = computeViewRectangle(this.scene); return viewRectangle ? Rectangle.contains( viewRectangle, Cartographic.fromCartesian(this.trs.translation) ) : false; } /** * Create event handlers for interacting with the box. */ private createEventHandler() { const scene = this.scene; let state: InteractionState = { is: "none" }; const handlePick = (click: MouseClick) => { const pick = scene.pick(click.position); const entity = pick?.id; if ( entity === undefined || !isInteractable(entity) || !this.dataSource.entities.contains(entity) ) { return; } if (state.is === "picked") { handleRelease(); } if (state.is === "hovering") { state.entity.onMouseOut({ startPosition: click.position, endPosition: click.position }); } state = { is: "picked", entity, beforePickState: { isFeaturePickingPaused: this.cesium.isFeaturePickingPaused, enableInputs: scene.screenSpaceCameraController.enableInputs } }; this.cesium.isFeaturePickingPaused = true; scene.screenSpaceCameraController.enableInputs = false; entity.onPick(click); }; const handleRelease = () => { if (state.is === "picked") { this.cesium.isFeaturePickingPaused = state.beforePickState.isFeaturePickingPaused; scene.screenSpaceCameraController.enableInputs = state.beforePickState.enableInputs; state.entity.onRelease(); state = { is: "none" }; } }; const detectHover = throttle((mouseMove: MouseMove) => { const pick = scene.pick(mouseMove.endPosition); const entity = pick?.id; if (entity === undefined || !isInteractable(entity)) { return; } state = { is: "hovering", entity }; entity.onMouseOver(mouseMove); }, 200); const scratchEndPosition = new Cartesian2(); const scratchStartPosition = new Cartesian2(); const handleMouseMove = (_mouseMove: MouseMove) => { const mouseMove = { endPosition: _mouseMove.endPosition.clone(scratchEndPosition), startPosition: _mouseMove.startPosition.clone(scratchStartPosition) }; if (state.is === "none") { detectHover(mouseMove); } else if (state.is === "hovering") { const pick = scene.pick(mouseMove.endPosition); const entity = pick?.id; if (entity !== state.entity) { state.entity.onMouseOut(mouseMove); if (entity && isInteractable(entity)) { state = { is: "hovering", entity }; entity.onMouseOver(mouseMove); } else { state = { is: "none" }; } } } else if (state.is === "picked") { state.entity.onDrag(mouseMove); } }; const eventHandler = new ScreenSpaceEventHandler(this.scene.canvas); eventHandler.setInputAction(handlePick, ScreenSpaceEventType.LEFT_DOWN); eventHandler.setInputAction(handleRelease, ScreenSpaceEventType.LEFT_UP); Object.values(KeyboardEventModifier).forEach( // Bind the release action to all LEFT_UP + any modifier. This is // required because we want the release to happen even if the user is by // chance pressing down on some other key. In such cases Cesium will not // trigger a LEFT_UP event unless we explicitly pass a modifier. (modifier) => eventHandler.setInputAction( handleRelease, ScreenSpaceEventType.LEFT_UP, modifier as any ) ); eventHandler.setInputAction( handleMouseMove, ScreenSpaceEventType.MOUSE_MOVE ); const onMouseOutCanvas = (e: MouseEvent) => { if (state.is === "hovering") { const { x, y } = e; state.entity.onMouseOut({ startPosition: new Cartesian2(x, y), endPosition: new Cartesian2(x, y) }); state = { is: "none" }; } }; scene.canvas.addEventListener("mouseout", onMouseOutCanvas); const handler = { destroy: () => { eventHandler.destroy(); // When destroying the eventHandler make sure we also release any // picked entities and not leave them hanging handleRelease(); scene.canvas.removeEventListener("mouseout", onMouseOutCanvas); } }; return handler; } /** * Draw the box. */ private drawBox() { this.drawSides(); this.drawEdges(); this.drawScalePoints(); } /** * Draw sides of the box. */ private drawSides() { SIDE_PLANES.forEach((sideLocal) => { const side = this.createSide(sideLocal); this.dataSource.entities.add(side); this.sides.push(side); }); } private drawEdges() { const localEdges: [Cartesian3, Cartesian3][] = []; CORNER_POINT_VECTORS.map((vector, i) => { const upPoint = vector; const downPoint = Cartesian3.clone(upPoint, new Cartesian3()); downPoint.z *= -1; const nextUpPoint = CORNER_POINT_VECTORS[(i + 1) % 4]; const nextDownPoint = Cartesian3.clone(nextUpPoint, new Cartesian3()); nextDownPoint.z *= -1; const verticalEdge: [Cartesian3, Cartesian3] = [upPoint, downPoint]; const topEdge: [Cartesian3, Cartesian3] = [nextUpPoint, upPoint]; const bottomEdge: [Cartesian3, Cartesian3] = [nextDownPoint, downPoint]; localEdges.push(verticalEdge, topEdge, bottomEdge); }); localEdges.forEach((localEdge) => { const edge = this.createEdge(localEdge); this.dataSource.entities.add(edge); this.edges.push(edge); }); } /** * Draw the scale grip points. */ private drawScalePoints() { const scalePointVectors = [ ...CORNER_POINT_VECTORS, ...(this.drawNonUniformScaleGrips ? FACE_POINT_VECTORS : []) ]; scalePointVectors.forEach((vector) => { const pointLocal1 = vector; const pointLocal2 = Cartesian3.multiplyByScalar( vector, -1, new Cartesian3() ); const scalePoint1 = this.createScalePoint( pointLocal1, Cartesian3.normalize( Cartesian3.subtract(pointLocal1, pointLocal2, new Cartesian3()), new Cartesian3() ) ); const scalePoint2 = this.createScalePoint( pointLocal2, Cartesian3.normalize( Cartesian3.subtract(pointLocal2, pointLocal1, new Cartesian3()), new Cartesian3() ) ); scalePoint1.oppositeScalePoint = scalePoint2; scalePoint2.oppositeScalePoint = scalePoint1; const axisLine = this.createScaleAxisLine(scalePoint1, scalePoint2); scalePoint1.axisLine = axisLine; scalePoint2.axisLine = axisLine; this.dataSource.entities.add(scalePoint1); this.dataSource.entities.add(scalePoint2); this.dataSource.entities.add(axisLine); this.scalePoints.push(scalePoint1, scalePoint2); }); } /** * Create a box side drawing. * * @param planeLocal A plane representing the side in local coordinates. * @returns side A cesium entity for the box side. */ private createSide(planeLocal: Plane): Side { const scene = this.scene; const plane = new Plane(Cartesian3.UNIT_X, 0); const planeDimensions = new Cartesian3(); const normalAxis = planeLocal.normal.x ? Axis.X : planeLocal.normal.y ? Axis.Y : Axis.Z; const style: Readonly<SideStyle> = { fillColor: Color.WHITE.withAlpha(0.1), outlineColor: Color.WHITE, highlightFillColor: Color.WHITE.withAlpha(0.2), highlightOutlineColor: Color.CYAN }; let isHighlighted = false; const scratchScaleMatrix = new Matrix4(); const update = () => { // From xyz scale set the scale for this plane based on the plane axis setPlaneDimensions(this.trs.scale, normalAxis, planeDimensions); // Transform the plane using scale matrix so that the plane distance is set correctly // Orientation and position are specified as entity parameters. const scaleMatrix = Matrix4.fromScale(this.trs.scale, scratchScaleMatrix); Plane.transform(planeLocal, scaleMatrix, plane); }; const scratchOutlineColor = new Color(); const side: Side = new Entity({ position: new CallbackProperty(() => this.trs.translation, false) as any, orientation: new CallbackProperty(() => this.trs.rotation, false) as any, plane: { show: true, plane: new CallbackProperty(() => plane, false), dimensions: new CallbackProperty(() => planeDimensions, false), fill: true, material: new ColorMaterialProperty( new CallbackProperty( () => (isHighlighted ? style.highlightFillColor : style.fillColor), false ) ), outline: true, outlineColor: new CallbackProperty( () => (isHighlighted ? style.highlightOutlineColor : style.outlineColor ).withAlpha(side.isFacingCamera ? 1 : 0.2, scratchOutlineColor), false ), outlineWidth: 1 } }) as Side; const axis = planeLocal.normal.x ? Axis.X : planeLocal.normal.y ? Axis.Y : Axis.Z; const scratchDirection = new Cartesian3(); const scratchMoveVector = new Cartesian3(); const scratchEllipsoid = new Ellipsoid(); const scratchRay = new Ray(); const scratchCartographic = new Cartographic(); const scratchPreviousPosition = new Cartesian3(); const scratchCartesian = new Cartesian3(); const scratchCurrentPosition = new Cartesian3(); const scratchMoveStep = new Cartesian3(); const scratchPickPosition = new Cartesian3(); const isTopOrBottomSide = axis === Axis.Z; const moveStartPos = new Cartesian2(); const pickedPointOffset = new Cartesian3(); let dragStart = false; let resetPosition = false; /** * Moves the box when dragging a side. * - When dragging the top or bottom sides, move the box up or down along the z-axis. * - When dragging any other sides move the box along the globe surface. */ const moveBoxOnDragSide = (mouseMove: MouseMove) => { const moveUpDown = axis === Axis.Z; let moveStep = scratchMoveStep; // Get the move direction const direction = Cartesian3.normalize( Matrix4.multiplyByPointAsVector( this.modelMatrix, plane.normal, scratchDirection ), scratchDirection ); if (moveUpDown) { // Move up or down when dragged on the top or bottom faces // moveAmount is proportional to the mouse movement along the provided direction const moveAmount = computeMoveAmount( scene, this.trs.translation, direction, mouseMove ); if (moveAmount !== undefined) { // Get the move vector const moveVector = Cartesian3.multiplyByScalar( direction, moveAmount, scratchMoveVector ); moveStep = moveVector; } } else if (this.keepHeightSteadyWhenMovingLaterally) { // Move the box laterally on the globe while keeping its height (almost) steady. // To do this: // 1. Find the exact point on the box surface mouse pick landed. // 2. Derive a new ellipsoid such that the picked point on the box // lies on the new ellipsoid surface. // 3. Find the point where the camera ray intersects this new // ellipsoid. This intersection point will have the same height as // that of the picked point while also visually coinciding with // the mouse cursor. // 4. Use this intersection point to compute the box moveStep if (dragStart) { // 1. Find the pick position. // When starting to drag, find the position on the box surface where // the user clicked and remember its distance from the box center. const pickedPosition = pickScenePosition( scene, mouseMove.endPosition, scratchPickPosition ); if (!pickedPosition) { return; } // Offset of the pick position from the center of the box Cartesian3.subtract( pickedPosition, this.trs.translation, pickedPointOffset ); dragStart = false; } // 2. Derive a new ellipsoid containing the pick position // Current cartesian position of the mouse pointer on the box surface const pickedPosition = Cartesian3.add( this.trs.translation, pickedPointOffset, scratchCurrentPosition ); const ellipsoid = scene.globe.ellipsoid; const pickedCartographicPosition = Cartographic.fromCartesian( pickedPosition, ellipsoid, scratchCartographic ); const pickedHeight = pickedCartographicPosition.height; // Derive an ellipsoid that passes through the pickedPoint const pickedPointEllipsoid = deriveEllipsoid( ellipsoid, pickedCartographicPosition, scratchEllipsoid ); // 3. Find the point where the camera ray intersects with the derived ellipsoid const cameraRay = scene.camera.getPickRay( mouseMove.endPosition, scratchRay ); if (!cameraRay) { return; } // Get the intersection between the camera ray and the derived ellipsoid // This will be the next position where the picked point should be const nextPosition = intersectRayEllipsoid( cameraRay, pickedPointEllipsoid, scratchCartesian ); if (!nextPosition) { // there is no intersection point return; } // Force the height of the nextPosition to the height of the picked point // This avoids small errors in height from accumulating setEllipsoidalHeight(nextPosition, pickedHeight, ellipsoid); // 4. Calculate the offset to move the box. moveStep = Cartesian3.subtract(nextPosition, pickedPosition, moveStep); } else { // Move box laterally by picking a next position that lies on the globe // surface and along the camera ray. const previousPosition = screenToGlobePosition( scene, mouseMove.startPosition, scratchPreviousPosition ) ?? scene.camera.pickEllipsoid( mouseMove.startPosition, undefined, scratchPreviousPosition ); const nextPosition = screenToGlobePosition( scene, mouseMove.endPosition, scratchCartesian ) ?? scene.camera.pickEllipsoid( mouseMove.endPosition, undefined, scratchCartesian ); if (!nextPosition || !previousPosition) { // We couldn't resolve a globe position, maybe because the mouse // cursor is pointing up in the sky. Reset the box position when we // can find the globe position again. resetPosition = true; return; } if (nextPosition && previousPosition) { if (resetPosition) { Cartesian3.clone(nextPosition, this.trs.translation); moveStep = Cartesian3.clone(Cartesian3.ZERO, moveStep); resetPosition = false; } else { Cartesian3.subtract(nextPosition, previousPosition, moveStep); } } } // Update box position and fire change event this.updateTerrainHeightEstimate(); this.moveBoxWithClamping(moveStep); this.updateBox(); this.onChange?.({ isFinished: false, modelMatrix: this.modelMatrix, translationRotationScale: this.trs }); }; const highlightSide = () => { isHighlighted = true; }; const unHighlightSide = () => { isHighlighted = false; }; const highlightAllSides = () => { this.sides.forEach((side) => side.highlight()); }; const unHighlightAllSides = () => this.sides.forEach((side) => side.unHighlight()); const onMouseOver = () => { highlightAllSides(); if (isTopOrBottomSide) { setCanvasCursor(scene, "n-resize"); } else { setCustomCanvasCursor(scene, "grab", "ew-resize"); } }; const onMouseOut = () => { unHighlightAllSides(); setCanvasCursor(scene, "auto"); }; const onPick = (click: MouseClick) => { Cartesian2.clone(click.position, moveStartPos); dragStart = true; highlightAllSides(); if (isTopOrBottomSide) { setCanvasCursor(scene, "n-resize"); } else { setCustomCanvasCursor(scene, "grabbing", "ew-resize"); } }; const onPickDisabled = () => { setCanvasCursor(scene, "not-allowed"); }; const onRelease = () => { this.setBoxAboveGround(); unHighlightAllSides(); setCanvasCursor(scene, "auto"); this.onChange?.({ modelMatrix: this.modelMatrix, translationRotationScale: this.trs, isFinished: true }); }; const scratchNormal = new Cartesian3(); const updateOnCameraChange = () => { const normalWc = Cartesian3.normalize( Matrix4.multiplyByPointAsVector( this.modelMatrix, plane.normal, scratchNormal ), scratchNormal ); // The side normals point inwards, so when facing the camera the camera // vector also points inwards which gives a positive dot product. side.isFacingCamera = Cartesian3.dot(normalWc, scene.camera.direction) >= 0; }; // Call enabledFn only if movement is is allowed for this side, otherwise call disabledFn const ifActionEnabled = ( enabledFn: (...args: any[]) => any, disabledFn?: (...args: any[]) => any ) => { return (...args: any[]) => { return this.disableVerticalMovement && isTopOrBottomSide ? disabledFn?.apply(this, args) : enabledFn.apply(this, args); }; }; side.onMouseOver = ifActionEnabled(onMouseOver); side.onMouseOut = ifActionEnabled(onMouseOut); side.onPick = ifActionEnabled(onPick, onPickDisabled); side.onDrag = ifActionEnabled(moveBoxOnDragSide); side.onRelease = ifActionEnabled(onRelease); side.highlight = highlightSide; side.unHighlight = unHighlightSide; side.isFacingCamera = false; side.updateOnCameraChange = updateOnCameraChange; side.update = update; side.isSide = true; update(); return side; } /** * Creates edges for the side specified as plane in local coordinates. */ private createEdge(localEdge: [Cartesian3, Cartesian3]): Edge { const scene = this.scene; const style = { color: Color.WHITE.withAlpha(0.1), highlightColor: Color.CYAN.withAlpha(0.7) }; const position1 = new Cartesian3(); const position2 = new Cartesian3(); const positions = [position1, position2]; // Only vertical edges are draggable const isDraggableEdge = localEdge[1].z - localEdge[0].z !== 0; const update = () => { Matrix4.multiplyByPoint(this.modelMatrix, localEdge[0], position1); Matrix4.multiplyByPoint(this.modelMatrix, localEdge[1], position2); }; let isHighlighted = false; const edge = new Entity({ polyline: { show: true, positions: new CallbackProperty(() => positions, false), width: new CallbackProperty(() => (isDraggableEdge ? 10 : 0), false), material: new ColorMaterialProperty( new CallbackProperty( () => (isHighlighted ? style.highlightColor : style.color), false ) ) as any, arcType: ArcType.NONE } }) as Edge; const onMouseOver = () => { if (isDraggableEdge) { isHighlighted = true; setCustomCanvasCursor(scene, "rotate", "pointer"); } }; const onMouseOut = () => { if (isDraggableEdge) { isHighlighted = false; setCanvasCursor(scene, "auto"); } }; const onPick = () => { if (isDraggableEdge) { isHighlighted = true; setCustomCanvasCursor(scene, "rotate", "pointer"); } }; const onRelease = () => { if (isDraggableEdge) { isHighlighted = false; setCanvasCursor(scene, "auto"); this.onChange?.({ isFinished: true, modelMatrix: this.modelMatrix, translationRotationScale: this.trs }); } }; const scratchHpr = new HeadingPitchRoll(0, 0, 0); const rotateBoxOnDrag = (mouseMove: MouseMove) => { if (!isDraggableEdge) { return; } const dx = mouseMove.endPosition.x - mouseMove.startPosition.x; const sensitivity = 0.05; const hpr = scratchHpr; // -dx because the screen coordinates is opposite to local coordinates space. hpr.heading = -dx * sensitivity; hpr.pitch = 0; hpr.roll = 0; Quaternion.multiply( this.trs.rotation, Quaternion.fromHeadingPitchRoll(hpr), this.trs.rotation ); this.updateBox(); this.updateEntitiesOnOrientationChange(); this.onChange?.({ isFinished: false, modelMatrix: this.modelMatrix, translationRotationScale: this.trs }); }; edge.update = update; edge.onMouseOver = onMouseOver; edge.onMouseOut = onMouseOut; edge.onPick = onPick; edge.onRelease = onRelease; edge.onDrag = rotateBoxOnDrag; update(); return edge; } /** * Creates a scale point drawing * * @param pointLocal The scale point in local coordinates. * @returns ScalePoint A cesium entity representing the scale point. */ private createScalePoint( pointLocal: Cartesian3, direction: Cartesian3 ): ScalePoint { const scene = this.scene; const position = new Cartesian3(); const offsetPosition = new Cartesian3(); const style: Readonly<ScalePointStyle> = { cornerPointColor: Color.RED.brighten(0.5, new Color()), facePointColor: Color.BLUE.brighten(0.5, new Color()), dimPointColor: Color.GREY.withAlpha(0.2) }; let isFacingCamera = false; const getColor = () => { return isFacingCamera ? isCornerPoint ? style.cornerPointColor : style.facePointColor : style.dimPointColor; }; const scalePointRadii = new Cartesian3(); const scratchBoundingSphere = new BoundingSphere(); const updateScalePointRadii = ( position: Cartesian3, boxScale: Cartesian3 ) => { // Get size of a pixel in metres at the position of the bounding shpere position.clone(scratchBoundingSphere.center); scratchBoundingSphere.radius = 1; const pixelSize = scene.camera.getPixelSize( scratchBoundingSphere, scene.drawingBufferWidth, scene.drawingBufferHeight ); const maxBoxScale = Cartesian3.maximumComponent(boxScale); // Compute radius equivalent to 10 pixels or 0.1 times the box scale whichever is smaller const radius = Math.min(pixelSize * 10, maxBoxScale * 0.1); scalePointRadii.x = radius; scalePointRadii.y = radius; scalePointRadii.z = radius; return scalePointRadii; }; const scratchOffset = new Cartesian3(); const scratchMatrix = new Matrix4(); const update = () => { // Update grip position Matrix4.multiplyByPoint(this.modelMatrix, pointLocal, position); // Update the size of scale points updateScalePointRadii(position, this.trs.scale); // Compute an offset for grips that lie on a face. Without the offset, // half of the grip will be inside the box thus reducing the clickable // surface area and creating a bad user experience. So, we want to push // most of the grip outside the box. Here we compute an offset 0.9 times // the radius of the point and in an outward direction from the center of // the box. const offset = isCornerPoint ? Cartesian3.ZERO // skip for corner points : Cartesian3.multiplyByScalar( // Transform the direction into world co-ordinates, but ignore the scaling Matrix4.multiplyByPointAsVector( Matrix4.setScale(this.modelMatrix, Cartesian3.ONE, scratchMatrix), direction, scratchOffset ), // assuming the grip point has uniform radii scalePointRadii.x * 0.9, scratchOffset ); Cartesian3.add(position, offset, offsetPosition); }; let isHighlighted = false; const scratchColor = new Color(); const scalePoint: ScalePoint = new Entity({ position: new CallbackProperty(() => offsetPosition, false) as any, orientation: new CallbackProperty( () => Quaternion.IDENTITY, false ) as any, // Sphere for the scale point ellipsoid: { radii: new CallbackProperty( // update scale point radii to reflect camera distance changes () => updateScalePointRadii(position, this.trs.scale), false ), material: new ColorMaterialProperty( new CallbackProperty( () => getColor().brighten(isHighlighted ? -0.5 : 0.0, scratchColor), false ) ) } }) as ScalePoint; // Calculate dot product with x, y & z axes const axisLocal = Cartesian3.normalize(pointLocal, new Cartesian3()); const xDot = Math.abs(Cartesian3.dot(new Cartesian3(1, 0, 0), axisLocal)); const yDot = Math.abs(Cartesian3.dot(new Cartesian3(0, 1, 0), axisLocal)); const zDot = Math.abs(Cartesian3.dot(new Cartesian3(0, 0, 1), axisLocal)); const isCornerPoint = xDot && yDot && zDot; const isProportionalScaling = isCornerPoint; // Return the angle in clockwise direction to rotate the mouse // cursor so that it points towards the center of the box. const getCursorRotation = (mousePos: Cartesian2) => { const boxCenter = scene.cartesianToCanvasCoordinates( this.trs.translation ); if (!boxCenter) { return undefined; } // mouse coords relative to the box center const x = mousePos.x - boxCenter.x; const y = mousePos.y - boxCenter.y; // Math.atan2 gives the angle the (x, y) point makes with the positive // x-axes in the clockwise direction const angle = CesiumMath.toDegrees(Math.atan2(y, x)); return angle; }; const onMouseOver = (mouseMove: MouseMove) => { scalePoint.axisLine.show = true; highlightScalePoint(); //cursor(mouseMove.endPosition); //setCanvasCursor(scene, cursorDirection); const cursorRotation = getCursorRotation(mouseMove.endPosition); setCustomCanvasCursor(scene, "resize", "ew-resize", cursorRotation); }; const onPick = (mouseClick: MouseClick) => { scalePoint.axisLine.show = true; highlightScalePoint(); const cursorRotation = getCursorRotation(mouseClick.position); setCustomCanvasCursor(scene, "resize", "ew-resize", cursorRotation); }; const onRelease = () => { scalePoint.axisLine.show = false; unHighlightScalePoint(); this.onChange?.({ modelMatrix: this.modelMatrix, translationRotationScale: this.trs, isFinished: true }); setCanvasCursor(scene, "auto"); }; const onMouseOut = () => { scalePoint.axisLine.show = false; unHighlightScalePoint(); setCanvasCursor(scene, "auto"); }; // Axis for proportional scaling const proportionalScalingAxis = new Cartesian3(1, 1, 1); const scratchOppositePosition = new Cartesian3(); const scratchAxisVector = new Cartesian3(); const scratchMoveDirection = new Cartesian3(); const scratchMultiply = new Cartesian3(); const scratchAbs = new Cartesian3(); const scratchScaleStep = new Cartesian3(); const scratchMoveStep = new Cartesian3(); const scratchCartographic = new Cartographic(); /** * Scales the box proportional to the mouse move when dragging the scale point. * Scaling occurs along the axis connecting the opposite scaling point. * Additionally we make sure: * - That scaling also keeps opposite side of the box stationary. * - The box does not get smaller than 20px on any side at the current zoom level. */ const scaleBoxOnDrag = (mouseMove: MouseMove) => { // Find the direction to scale in const oppositePosition = scalePoint.oppositeScalePoint.position.getValue( JulianDate.now(), scratchOppositePosition ); if (oppositePosition === undefined) return; const axisVector = Cartesian3.subtract( position, oppositePosition, scratchAxisVector ); const length = Cartesian3.magnitude(axisVector); const scaleDirection = Cartesian3.normalize( axisVector, scratchMoveDirection ); // scaleAmount is a measure of how much to scale in the given direction // for the given mouse movement. const scaleResult = computeScaleAmount( this.scene, position, scaleDirection, length, mouseMove ); if (!scaleResult) { return; } const { scaleAmount, pixelLengthAfterScaling } = scaleResult; // When downscaling, stop at 20px length. if (scaleAmount < 0) { const isDiagonal = axisLocal.x && axisLocal.y && axisLocal.y; const pixelSideLengthAfterScaling = isDiagonal ? pixelLengthAfterScaling / Math.sqrt(2) : pixelLengthAfterScaling; if (pixelSideLengthAfterScaling < 20) { // Do nothing if scaling down will make the box smaller than 20px return; } } // Compute scale components along xyz const scaleStep = Cartesian3.multiplyByScalar( // Taking abs because scaling step is independent of axis direction // Scaling step is negative when scaling down and positive when scaling up Cartesian3.abs( // Extract scale components along the axis Cartesian3.multiplyComponents( this.trs.scale, // For proportional scaling we scale equally along xyz isProportionalScaling ? proportionalScalingAxis : axisLocal, scratchMultiply ), scratchAbs ), scaleAmount, scratchScaleStep ); // Move the box by half the scale amount in the direction of scaling so // that the opposite end remains stationary. const moveStep = Cartesian3.multiplyByScalar( axisVector, scaleAmount / 2, scratchMoveStep ); // Prevent scaling in Z axis if it will result in the box going underground. const isDraggingBottomScalePoint = axisLocal.z < 0; const isUpscaling = scaleAmount > 0; if ( this.keepBoxAboveGround && isUpscaling && isDraggingBottomScalePoint ) { const boxCenterHeight = Cartographic.fromCartesian( this.trs.translation, undefined, scratchCartographic ).height; const bottomHeight = boxCenterHeight - this.trs.scale.z / 2; const bottomHeightAfterScaling = bottomHeight - Math.abs(moveStep.z);