// Assumptions: absolute position of button mesh is inside the mesh import { DeepImmutableObject } from "babylonjs/types"; import { Vector3, Quaternion } from "babylonjs/Maths/math.vector"; import { Mesh } from "babylonjs/Meshes/mesh"; import { AbstractMesh } from "babylonjs/Meshes/abstractMesh"; import { TransformNode } from "babylonjs/Meshes/transformNode"; import { Scene } from "babylonjs/scene"; import { Ray } from "babylonjs/Culling/ray"; import { Button3D } from "./button3D"; /** * Enum for Button States */ enum ButtonState { /** None */ None = 0, /** Pointer Entered */ Hover = 1, /** Pointer Down */ Press = 2 } /** * Class used to create a touchable button in 3D */ export class TouchButton3D extends Button3D { private _collisionMesh: Mesh; private _collidableFrontDirection: Vector3; private _lastTouchPoint: Vector3; private _tempButtonForwardRay: Ray; private _lastKnownCollidableScale: Vector3; private _collidableInitialized = false; private _frontOffset = 0; private _backOffset = 0; private _hoverOffset = 0; private _pushThroughBackOffset = 0; private _activeInteractions = new Map(); private _previousHeight = new Map(); /** * Creates a new touchable button * @param name defines the control name * @param collisionMesh mesh to track collisions with */ constructor(name?: string, collisionMesh?: Mesh) { super(name); this._tempButtonForwardRay = new Ray(Vector3.Zero(), Vector3.Zero()); if (collisionMesh) { this.collisionMesh = collisionMesh; } } /** * Sets the front-facing direction of the button * @param frontDir the forward direction of the button */ public set collidableFrontDirection(frontWorldDir: Vector3) { this._collidableFrontDirection = frontWorldDir.normalize(); // Zero out the scale to force it to be proplerly updated in _updateDistanceOffsets this._lastKnownCollidableScale = Vector3.Zero(); this._updateDistanceOffsets(); } private _getWorldMatrixData(mesh: Mesh) { let translation = Vector3.Zero(); let rotation = Quaternion.Identity(); let scale = Vector3.Zero(); mesh.getWorldMatrix().decompose(scale, rotation, translation); return {translation: translation, rotation: rotation, scale: scale}; } /** * Sets the mesh used for testing input collision * @param collisionMesh the new collision mesh for the button */ public set collisionMesh(collisionMesh: Mesh) { if (this._collisionMesh) { this._collisionMesh.dispose(); } // parent the mesh to sync transforms if (!collisionMesh.parent && this.mesh) { collisionMesh.setParent(this.mesh); } this._collisionMesh = collisionMesh; this._collisionMesh.metadata = this; this.collidableFrontDirection = collisionMesh.forward; this._collidableInitialized = true; } /* * Given a point, and two points on a line, this returns the distance between * the point and the closest point on the line. The closest point on the line * does not have to be between the two given points. * * Based off the 3D point-line distance equation * * Assumes lineDirection is normalized */ private _getShortestDistancePointToLine(point: Vector3, linePoint: Vector3, lineDirection: Vector3) { const pointToLine = linePoint.subtract(point); const cross = lineDirection.cross(pointToLine); return cross.length(); } /* * Checks to see if collidable is in a position to interact with the button * - check if collidable has a plane height off the button that is within range * - check that collidable + normal ray intersect the bounding sphere */ private _isPrimedForInteraction(collidable: Vector3): boolean { // Check if the collidable has an appropriate planar height const heightFromCenter = this._getHeightFromButtonCenter(collidable); if (heightFromCenter > this._hoverOffset || heightFromCenter < this._pushThroughBackOffset) { return false; } // Check if the collidable or its hover ray lands within the bounding sphere of the button const distanceFromCenter = this._getShortestDistancePointToLine(this._collisionMesh.getAbsolutePosition(), collidable, this._collidableFrontDirection); return distanceFromCenter <= this._collisionMesh.getBoundingInfo().boundingSphere.radiusWorld; } /* * Returns a Vector3 of the collidable's projected position on the button * Returns the collidable's position if it is inside the button */ private _getPointOnButton(collidable: Vector3): Vector3 { const heightFromCenter = this._getHeightFromButtonCenter(collidable); if (heightFromCenter <= this._frontOffset && heightFromCenter >= this._backOffset) { // The collidable is in the button, return its position return collidable; } else if (heightFromCenter > this._frontOffset) { // The collidable is in front of the button, project it to the surface const collidableDistanceToFront = (this._frontOffset - heightFromCenter); return collidable.add(this._collidableFrontDirection.scale(collidableDistanceToFront)); } else { // The collidable is behind the button, project it to its back const collidableDistanceToBack = (this._backOffset - heightFromCenter); return collidable.add(this._collidableFrontDirection.scale(collidableDistanceToBack)); } } /* * Updates the distance values. * Should be called when the front direction changes, or the mesh size changes * * Sets the following values: * _frontOffset * _backOffset * _hoverOffset * _pushThroughBackOffset * * Requires population of: * _collisionMesh * _collidableFrontDirection */ private _updateDistanceOffsets() { let worldMatrixData = this._getWorldMatrixData(this._collisionMesh); if (!worldMatrixData.scale.equalsWithEpsilon(this._lastKnownCollidableScale)) { const collisionMeshPos = this._collisionMesh.getAbsolutePosition(); this._tempButtonForwardRay.origin = collisionMeshPos; this._tempButtonForwardRay.direction = this._collidableFrontDirection; const frontPickingInfo = this._tempButtonForwardRay.intersectsMesh(this._collisionMesh as DeepImmutableObject); this._tempButtonForwardRay.direction = this._tempButtonForwardRay.direction.negate(); const backPickingInfo = this._tempButtonForwardRay.intersectsMesh(this._collisionMesh as DeepImmutableObject); this._frontOffset = 0; this._backOffset = 0; if (frontPickingInfo.hit && backPickingInfo.hit) { this._frontOffset = this._getDistanceOffPlane(frontPickingInfo.pickedPoint!, this._collidableFrontDirection, collisionMeshPos); this._backOffset = this._getDistanceOffPlane(backPickingInfo.pickedPoint!, this._collidableFrontDirection, collisionMeshPos); } // For now, set the hover height equal to the thickness of the button const buttonThickness = this._frontOffset - this._backOffset; this._hoverOffset = this._frontOffset + (buttonThickness * 1.25); this._pushThroughBackOffset = this._backOffset - (buttonThickness * 1.5); this._lastKnownCollidableScale = this._getWorldMatrixData(this._collisionMesh).scale; } } // Returns the distance in front of the center of the button // Returned value is negative when collidable is past the center private _getHeightFromButtonCenter(collidablePos: Vector3) { return this._getDistanceOffPlane(collidablePos, this._collidableFrontDirection, this._collisionMesh.getAbsolutePosition()); } // Returns the distance from pointOnPlane to point along planeNormal private _getDistanceOffPlane(point: Vector3, planeNormal: Vector3, pointOnPlane: Vector3) { const d = Vector3.Dot(pointOnPlane, planeNormal); const abc = Vector3.Dot(point, planeNormal); return abc - d; } // Updates the stored state of the button, and fire pointer events private _updateButtonState(id: number, newState: ButtonState, pointOnButton: Vector3) { const dummyPointerId = 0; const buttonIndex = 0; // Left click const buttonStateForId = this._activeInteractions.get(id) || ButtonState.None; // Take into account all inputs interacting with the button to avoid state flickering let previousPushDepth = 0; this._activeInteractions.forEach(function(value, key) { previousPushDepth = Math.max(previousPushDepth, value); }); if (buttonStateForId != newState) { if (newState == ButtonState.None) { this._activeInteractions.delete(id); } else { this._activeInteractions.set(id, newState); } } let newPushDepth = 0; this._activeInteractions.forEach(function(value, key) { newPushDepth = Math.max(newPushDepth, value); }); if (newPushDepth == ButtonState.Press) { if (previousPushDepth == ButtonState.Hover) { this._onPointerDown(this, pointOnButton, dummyPointerId, buttonIndex); } else if (previousPushDepth == ButtonState.Press) { this._onPointerMove(this, pointOnButton); } } else if (newPushDepth == ButtonState.Hover) { if (previousPushDepth == ButtonState.None) { this._onPointerEnter(this); } else if (previousPushDepth == ButtonState.Press) { this._onPointerUp(this, pointOnButton, dummyPointerId, buttonIndex, false); } else { this._onPointerMove(this, pointOnButton); } } else if (newPushDepth == ButtonState.None) { if (previousPushDepth == ButtonState.Hover) { this._onPointerOut(this); } else if (previousPushDepth == ButtonState.Press) { this._onPointerUp(this, pointOnButton, dummyPointerId, buttonIndex, false); this._onPointerOut(this); } } } // Decides whether to change button state based on the planar depth of the input source /** @hidden */ public _collisionCheckForStateChange(mesh: AbstractMesh) { if (this._collidableInitialized) { this._updateDistanceOffsets(); const collidablePosition = mesh.getAbsolutePosition(); const inRange = this._isPrimedForInteraction(collidablePosition); const uniqueId = mesh.uniqueId; let activeInteraction = this._activeInteractions.get(uniqueId); if (inRange) { const pointOnButton = this._getPointOnButton(collidablePosition); const heightFromCenter = this._getHeightFromButtonCenter(collidablePosition); const flickerDelta = 0.003; this._lastTouchPoint = pointOnButton; const isGreater = function (compareHeight: number) { return heightFromCenter >= (compareHeight + flickerDelta); }; const isLower = function (compareHeight: number) { return heightFromCenter <= (compareHeight - flickerDelta); }; // Update button state and fire events switch (activeInteraction || ButtonState.None) { case ButtonState.None: if (isGreater(this._frontOffset) && isLower(this._hoverOffset)) { this._updateButtonState(uniqueId, ButtonState.Hover, pointOnButton); } break; case ButtonState.Hover: if (isGreater(this._hoverOffset)) { this._updateButtonState(uniqueId, ButtonState.None, pointOnButton); } else if (isLower(this._frontOffset)) { this._updateButtonState(uniqueId, ButtonState.Press, pointOnButton); } break; case ButtonState.Press: if (isGreater(this._frontOffset)) { this._updateButtonState(uniqueId, ButtonState.Hover, pointOnButton); } else if (isLower(this._pushThroughBackOffset)) { this._updateButtonState(uniqueId, ButtonState.None, pointOnButton); } break; } this._previousHeight.set(uniqueId, heightFromCenter); } else if ((activeInteraction != undefined) && (activeInteraction != ButtonState.None)) { this._updateButtonState(uniqueId, ButtonState.None, this._lastTouchPoint); this._previousHeight.delete(uniqueId); } } } protected _getTypeName(): string { return "TouchButton3D"; } // Mesh association protected _createNode(scene: Scene): TransformNode { return super._createNode(scene); } /** * Releases all associated resources */ public dispose() { super.dispose(); if (this._collisionMesh) { this._collisionMesh.dispose(); } } }