touchButton3D.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. // Assumptions: absolute position of button mesh is inside the mesh
  2. import { DeepImmutableObject } from "babylonjs/types";
  3. import { Vector3 } from "babylonjs/Maths/math.vector";
  4. import { Mesh } from "babylonjs/Meshes/mesh";
  5. import { AbstractMesh } from "babylonjs/Meshes/abstractMesh";
  6. import { TransformNode } from "babylonjs/Meshes/transformNode";
  7. import { Scene } from "babylonjs/scene";
  8. import { Ray } from "babylonjs/Culling/ray";
  9. import { Button3D } from "./button3D";
  10. /**
  11. * Enum for Button States
  12. */
  13. export enum ButtonState {
  14. /** None */
  15. None = 0,
  16. /** Pointer Entered */
  17. Hover = 1,
  18. /** Pointer Down */
  19. Press = 2
  20. }
  21. /**
  22. * Class used to create a touchable button in 3D
  23. */
  24. export class TouchButton3D extends Button3D {
  25. /** @hidden */
  26. //private _buttonState: ButtonState;
  27. private _collisionMesh: Mesh;
  28. private _collidableFrontDirection: Vector3;
  29. private _lastTouchPoint: Vector3;
  30. private _collidableInitialized = false;
  31. private _offsetToFront = 0;
  32. private _offsetToBack = 0;
  33. private _hoverOffset = 0;
  34. private _activeInteractions = new Map<number, ButtonState>();
  35. private _previousHeight = new Map<number, number>();
  36. /**
  37. * Creates a new button
  38. * @param collisionMesh mesh to track collisions with
  39. * @param name defines the control name
  40. */
  41. constructor(name?: string, collisionMesh?: Mesh) {
  42. super(name);
  43. if (collisionMesh) {
  44. this.collisionMesh = collisionMesh;
  45. }
  46. }
  47. public set collidableFrontDirection(frontDir: Vector3) {
  48. this._collidableFrontDirection = frontDir.normalize();
  49. this._updateDistances();
  50. }
  51. public set collisionMesh(collisionMesh: Mesh) {
  52. if (this._collisionMesh) {
  53. this._collisionMesh.dispose();
  54. }
  55. this._collisionMesh = collisionMesh;
  56. this._collisionMesh.metadata = this;
  57. this.collidableFrontDirection = collisionMesh.forward;
  58. this._collidableInitialized = true;
  59. }
  60. /*
  61. * Given a point, and two points on a line, this returns the distance between
  62. * the point and the closest point on the line. The closest point on the line
  63. * does not have to be between the two given points.
  64. *
  65. * Based off the 3D point-line distance equation
  66. *
  67. * Assumes lineDirection is normalized
  68. */
  69. private _getShortestDistancePointToLine(point: Vector3, linePoint: Vector3, lineDirection: Vector3) {
  70. const pointToLine = linePoint.subtract(point);
  71. const cross = lineDirection.cross(pointToLine);
  72. return cross.length();
  73. }
  74. /*
  75. * Checks to see if collidable is in a position to interact with the button
  76. * - check if collidable has a plane height within tolerance (between back/front?)
  77. * - check that collidable + normal ray intersect the bounding sphere
  78. */
  79. private _isPrimedForInteraction(collidable: Vector3): boolean {
  80. // Check if the collidable has an appropriate planar height
  81. const heightFromCenter = this._getHeightFromButtonCenter(collidable);
  82. const heightPadding = (this._offsetToFront - this._offsetToBack) / 2;
  83. if (heightFromCenter > (this._hoverOffset + heightPadding) || heightFromCenter < (this._offsetToBack - heightPadding)) {
  84. return false;
  85. }
  86. // Check if the collidable or its hover ray lands within the bounding sphere of the button
  87. const distanceFromCenter = this._getShortestDistancePointToLine(this._collisionMesh.getAbsolutePosition(),
  88. collidable,
  89. this._collidableFrontDirection);
  90. return distanceFromCenter <= this._collisionMesh.getBoundingInfo().boundingSphere.radiusWorld;
  91. }
  92. /*
  93. * Returns a Vector3 of the collidable's projected position on the button
  94. * Returns the collidable's position if it is inside the button
  95. */
  96. private _getPointOnButton(collidable: Vector3): Vector3 {
  97. const heightFromCenter = this._getHeightFromButtonCenter(collidable);
  98. if (heightFromCenter <= this._offsetToFront && heightFromCenter >= this._offsetToBack) {
  99. // The collidable is in the button, return its position
  100. return collidable;
  101. }
  102. else if (heightFromCenter > this._offsetToFront) {
  103. // The collidable is in front of the button, project it to the surface
  104. const collidableDistanceToFront = (this._offsetToFront - heightFromCenter);
  105. return collidable.add(this._collidableFrontDirection.scale(collidableDistanceToFront));
  106. }
  107. else {
  108. // The collidable is behind the button, project it to its back
  109. const collidableDistanceToBack = (this._offsetToBack - heightFromCenter);
  110. return collidable.add(this._collidableFrontDirection.scale(collidableDistanceToBack));
  111. }
  112. }
  113. /*
  114. * Updates the distance values.
  115. * Should be called when the front direction changes, or the mesh size changes
  116. *
  117. * Sets the following values:
  118. * _offsetToFront
  119. * _offsetToBack
  120. *
  121. * Requires population of:
  122. * _collisionMesh
  123. * _collidableFrontDirection
  124. */
  125. private _updateDistances() {
  126. const collisionMeshPos = this._collisionMesh.getAbsolutePosition();
  127. const normalRay = new Ray(collisionMeshPos, this._collidableFrontDirection);
  128. const frontPickingInfo = normalRay.intersectsMesh(this._collisionMesh as DeepImmutableObject<AbstractMesh>);
  129. normalRay.direction = normalRay.direction.negate();
  130. const backPickingInfo = normalRay.intersectsMesh(this._collisionMesh as DeepImmutableObject<AbstractMesh>);
  131. this._offsetToFront = 0;
  132. this._offsetToBack = 0;
  133. if (frontPickingInfo.hit && backPickingInfo.hit) {
  134. this._offsetToFront = this._getDistanceOffPlane(frontPickingInfo.pickedPoint!,
  135. this._collidableFrontDirection,
  136. collisionMeshPos);
  137. this._offsetToBack = this._getDistanceOffPlane(backPickingInfo.pickedPoint!,
  138. this._collidableFrontDirection,
  139. collisionMeshPos);
  140. }
  141. // For now, set the hover height equal to the thickness of the button
  142. const buttonThickness = this._offsetToFront - this._offsetToBack;
  143. this._hoverOffset = buttonThickness + this._offsetToFront;
  144. }
  145. // Returns the distance in front of the center of the button
  146. // Returned value is negative when collidable is past the center
  147. private _getHeightFromButtonCenter(collidablePos: Vector3) {
  148. return this._getDistanceOffPlane(collidablePos, this._collidableFrontDirection, this._collisionMesh.getAbsolutePosition());
  149. }
  150. // Returns the distance from pointOnPlane to point along planeNormal
  151. // Very cheap
  152. private _getDistanceOffPlane(point: Vector3, planeNormal: Vector3, pointOnPlane: Vector3) {
  153. const d = Vector3.Dot(pointOnPlane, planeNormal);
  154. const abc = Vector3.Dot(point, planeNormal);
  155. return abc - d;
  156. }
  157. private _updateButtonState(id: number, newState: ButtonState, pointOnButton: Vector3) {
  158. const dummyPointerId = 0;
  159. const buttonIndex = 0; // Left click
  160. const buttonStateForId = this._activeInteractions.get(id) || ButtonState.None;
  161. // Take into account all inputs interacting with the button to avoid state flickering
  162. let previousPushDepth = 0;
  163. this._activeInteractions.forEach(function(value, key) {
  164. previousPushDepth = Math.max(previousPushDepth, value);
  165. });
  166. if (buttonStateForId != newState) {
  167. if (newState == ButtonState.None) {
  168. this._activeInteractions.delete(id);
  169. }
  170. else {
  171. this._activeInteractions.set(id, newState);
  172. }
  173. }
  174. let newPushDepth = 0;
  175. this._activeInteractions.forEach(function(value, key) {
  176. newPushDepth = Math.max(newPushDepth, value);
  177. });
  178. if (newPushDepth == ButtonState.Press) {
  179. if (previousPushDepth == ButtonState.Hover) {
  180. this._onPointerDown(this, pointOnButton, dummyPointerId, buttonIndex);
  181. }
  182. else if (previousPushDepth == ButtonState.Press) {
  183. this._onPointerMove(this, pointOnButton);
  184. }
  185. }
  186. else if (newPushDepth == ButtonState.Hover) {
  187. if (previousPushDepth == ButtonState.None) {
  188. this._onPointerEnter(this);
  189. }
  190. else if (previousPushDepth == ButtonState.Press) {
  191. this._onPointerUp(this, pointOnButton, dummyPointerId, buttonIndex, false);
  192. }
  193. else {
  194. this._onPointerMove(this, pointOnButton);
  195. }
  196. }
  197. else if (newPushDepth == ButtonState.None) {
  198. if (previousPushDepth == ButtonState.Hover) {
  199. this._onPointerOut(this);
  200. }
  201. else if (previousPushDepth == ButtonState.Press) {
  202. this._onPointerUp(this, pointOnButton, dummyPointerId, buttonIndex, false);
  203. this._onPointerOut(this);
  204. }
  205. }
  206. }
  207. protected _getTypeName(): string {
  208. return "TouchButton3D";
  209. }
  210. protected _enableCollisions(scene: Scene, collisionMesh?: Mesh) {
  211. var _this = this;
  212. if (collisionMesh) {
  213. this.collisionMesh = collisionMesh;
  214. }
  215. // TODO?: Set distances appropriately:
  216. // Hover depth based on distance from front face of mesh, not center
  217. // (DONE) Touch Depth based on actual collision with button
  218. // (DONE?) HitTestDistance based on distance from front face of button
  219. // (DONE) For the hover/hitTest, compute point-plane distance, using button front for plane
  220. // -> Right now only have front direction. Can't rely on mesh for getting front face
  221. // since mesh might not be aligned properly... Make that a requirement?
  222. const onBeforeRender = function () {
  223. if (_this._collidableInitialized) {
  224. const touchMeshes = scene.getMeshesByTags("touchEnabled");
  225. touchMeshes.forEach(function (mesh: Mesh) {
  226. const collidablePosition = mesh.getAbsolutePosition();
  227. const inRange = _this._isPrimedForInteraction(collidablePosition);
  228. const uniqueId = mesh.uniqueId;
  229. if (inRange) {
  230. const pointOnButton = _this._getPointOnButton(collidablePosition);
  231. const heightFromCenter = _this._getHeightFromButtonCenter(collidablePosition);
  232. const flickerDelta = 0.003;
  233. _this._lastTouchPoint = pointOnButton;
  234. const isGreater = function (compareHeight: number) {
  235. return heightFromCenter >= (compareHeight + flickerDelta);
  236. };
  237. const isLower = function (compareHeight: number) {
  238. return heightFromCenter <= (compareHeight - flickerDelta);
  239. };
  240. // Update button state and fire events
  241. switch(_this._activeInteractions.get(uniqueId) || ButtonState.None) {
  242. case ButtonState.None:
  243. if (isGreater(_this._offsetToFront) &&
  244. isLower(_this._hoverOffset)) {
  245. _this._updateButtonState(uniqueId, ButtonState.Hover, pointOnButton);
  246. }
  247. break;
  248. case ButtonState.Hover:
  249. if (isGreater(_this._hoverOffset)) {
  250. _this._updateButtonState(uniqueId, ButtonState.None, pointOnButton);
  251. }
  252. else if (isLower(_this._offsetToFront)) {
  253. _this._updateButtonState(uniqueId, ButtonState.Press, pointOnButton);
  254. }
  255. break;
  256. case ButtonState.Press:
  257. if (isGreater(_this._offsetToFront)) {
  258. _this._updateButtonState(uniqueId, ButtonState.Hover, pointOnButton);
  259. }
  260. else if (isLower(_this._offsetToBack)) {
  261. _this._updateButtonState(uniqueId, ButtonState.None, pointOnButton);
  262. }
  263. break;
  264. }
  265. _this._previousHeight.set(uniqueId, heightFromCenter);
  266. }
  267. else {
  268. _this._updateButtonState(uniqueId, ButtonState.None, _this._lastTouchPoint);
  269. _this._previousHeight.delete(uniqueId);
  270. }
  271. });
  272. }
  273. };
  274. scene.registerBeforeRender(onBeforeRender);
  275. }
  276. // Mesh association
  277. protected _createNode(scene: Scene): TransformNode {
  278. this._enableCollisions(scene);
  279. return super._createNode(scene);
  280. }
  281. /**
  282. * Releases all associated resources
  283. */
  284. public dispose() {
  285. super.dispose();
  286. if (this._collisionMesh) {
  287. this._collisionMesh.dispose();
  288. }
  289. }
  290. }