UNPKG

terriajs

Version:

Geospatial data visualization platform.

1,091 lines 58.3 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; 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 PolylineDashMaterialProperty from "terriajs-cesium/Source/DataSources/PolylineDashMaterialProperty"; import Axis from "terriajs-cesium/Source/Scene/Axis"; import isDefined from "../Core/isDefined"; import { getCustomCssCursor } from "./BoxDrawing/cursors"; // The 6 box sides defined as planes in local coordinate space. const SIDE_PLANES = [ 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) { return typeof entity.update === "function"; } /** * Checks whether the given entity is interactable. */ function isInteractable(entity) { return (typeof entity.onPick === "function" && typeof entity.onRelease === "function" && typeof entity.onMouseOver === "function" && typeof entity.onMouseOut === "function"); } export function isSideEntity(entity) { return entity.isSide; } export default class BoxDrawing { cesium; static localSidePlanes = SIDE_PLANES; // Observable because we want to start/stop interactions when the datasource // gets used/removed. dataSource; _keepBoxAboveGround = false; drawNonUniformScaleGrips; disableVerticalMovement; keepHeightSteadyWhenMovingLaterally = true; onChange; // An external transform to convert the box in local coordinates to world coordinates worldTransform = Matrix4.IDENTITY.clone(); // The translation, rotation & scale (i.e position, orientation, dimensions) of the box trs = new TranslationRotationScale(); // A matrix representation of trs modelMatrix = Matrix4.IDENTITY.clone(); scene; // A disposer function to destroy all event handlers interactionsDisposer; // Sides of the box defined as cesium entities with additional properties sides = []; // Scale points on the box defined as cesium entities with additional properties scalePoints = []; edges = []; isHeightUpdateInProgress = false; terrainHeightEstimate = 0; // Flag to turn scaling interaction on or off _enableScaling = true; // Flag to turn rotation interaction on or off _enableRotation = true; /** * A private constructor. Use {@link BoxDrawing.fromTransform} or {@link BoxDrawing.fromTranslationRotationScale} to create instances. */ constructor(cesium, transform, options) { this.cesium = cesium; 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, transform, options) { 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, trs, options) { const boxDrawing = new BoxDrawing(cesium, Matrix4.fromTranslationRotationScale(trs), options ?? {}); return boxDrawing; } setTranslationRotationScale(trs) { 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. */ setTransform(transform) { 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) { 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) { this._enableScaling = enable; this.scalePoints.forEach((scalePoint) => (scalePoint.show = enable)); } get enableRotation() { return this._enableRotation; } set enableRotation(enable) { 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 */ moveBoxWithClamping = (() => { const scratchNewPosition = new Cartesian3(); const scratchCartographic = new Cartographic(); return (moveStep) => { 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 = 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) { 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. */ 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() { 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. */ startInteractions() { if (this.interactionsDisposer) { // already started return; } let eventHandler; // 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(); } stopInteractions() { this.interactionsDisposer?.(); } /** * Updates all box parameters from changes to the localTransform. */ updateBox() { Matrix4.fromTranslationRotationScale(this.trs, this.modelMatrix); this.dataSource.entities.values.forEach((entity) => { if (isUpdatable(entity)) entity.update(); }); } updateEntitiesOnOrientationChange() { this.sides.forEach((side) => side.updateOnCameraChange()); this.scalePoints.forEach((scalePoint) => scalePoint.updateOnCameraChange()); } /** * Returns true if the box is in camera view. */ 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. */ createEventHandler() { const scene = this.scene; let state = { is: "none" }; const handlePick = (click) => { 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) => { 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) => { 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)); eventHandler.setInputAction(handleMouseMove, ScreenSpaceEventType.MOUSE_MOVE); const onMouseOutCanvas = (e) => { 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. */ drawBox() { this.drawSides(); this.drawEdges(); this.drawScalePoints(); } /** * Draw sides of the box. */ drawSides() { SIDE_PLANES.forEach((sideLocal) => { const side = this.createSide(sideLocal); this.dataSource.entities.add(side); this.sides.push(side); }); } drawEdges() { const localEdges = []; 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 = [upPoint, downPoint]; const topEdge = [nextUpPoint, upPoint]; const bottomEdge = [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. */ 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. */ createSide(planeLocal) { 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 = { 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 = new Entity({ position: new CallbackProperty(() => this.trs.translation, false), orientation: new CallbackProperty(() => this.trs.rotation, false), 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 } }); 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) => { 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) => { 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, disabledFn) => { return (...args) => { 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. */ createEdge(localEdge) { 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)), arcType: ArcType.NONE } }); 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) => { 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. */ createScalePoint(pointLocal, direction) { const scene = this.scene; const position = new Cartesian3(); const offsetPosition = new Cartesian3(); const style = { 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, boxScale) => { // 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 = new Entity({ position: new CallbackProperty(() => offsetPosition, false), orientation: new CallbackProperty(() => Quaternion.IDENTITY, false), // 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)) } }); // 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) => { 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) => { 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) => { 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) => { // 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); if (bottomHeightAfterScaling < 0) { scaleStep.z = 0; } } // Apply scale Cartesian3.add(this.trs.scale, scaleStep, this.trs.scale); // Move the box this.moveBoxWithClamping(moveStep); this.updateBox(); this.onChange?.({ isFinished: false, modelMatrix: this.modelMatrix, translationRotationScale: this.trs }); }; const adjacentSides = this.sides.filter((side) => { const plane = side.plane.plane?.getValue(JulianDate.now()); const isAdjacent = Cartesian3.dot(plane.normal, axisLocal) < 0; return isAdjacent; }); const updateOnCameraChange = () => { isFacingCamera = adjacentSides.some((side) => side.isFacingCamera); }; const highlightScalePoint = () => { isHighlighted = true; }; const unHighlightScalePoint = () => { isHighlighted = false; }; scalePoint.onPick = onPick; scalePoint.o