threepipe
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

InteractionPromptPlugin.ts 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. import {Spherical, Vector3} from 'three'
  2. import {IEvent, now, objectHasOwn, onChange, serialize} from 'ts-browser-helpers'
  3. import {AViewerPluginSync, ThreeViewer} from '../../viewer'
  4. import {uiButton, uiFolderContainer, uiInput, uiMonitor, uiToggle} from 'uiconfig.js'
  5. import {OrbitControls3} from '../../three'
  6. /**
  7. * Interaction Prompt Plugin
  8. *
  9. * A plugin that adds a hand pointer icon over the canvas that moves to prompt the user to interact with the 3d scene.
  10. * Pointer icon from [google/model-viewer](https://github.com/google/model-viewer)
  11. *
  12. * The pointer is automatically shown when some object is in the scene and the camera is not moving.
  13. * The animation starts after a delay and stops on user interaction. It then restarts after a delay after the user stops interacting
  14. *
  15. * The plugin provides several options and functions to configure the automatic behaviour or trigger the animation manually.
  16. * @todo - create example
  17. * @category Plugins
  18. */
  19. @uiFolderContainer('Interaction Prompt')
  20. export class InteractionPromptPlugin extends AViewerPluginSync<''> {
  21. static readonly PluginType = 'InteractionPromptPlugin'
  22. @serialize()
  23. @uiToggle() enabled
  24. currentSphericalPosition?: Spherical
  25. animationRunning = false
  26. cursorEl?: HTMLElement
  27. // interactionsDisabled = false
  28. /**
  29. * Animation duration in ms
  30. */
  31. @serialize()
  32. @uiInput() animationDuration = 2000
  33. /**
  34. * Animation distance in pixels
  35. */
  36. @serialize()
  37. @uiInput() animationDistance = 80
  38. @serialize()
  39. @uiInput() animationPauseDuration = 6000
  40. /**
  41. * Camera Rotation distance in radians.
  42. */
  43. @serialize()
  44. @uiInput() rotationDistance = 0.3
  45. /**
  46. * Move the pointer icon up or down.
  47. * Y offset in the range -1 to 1.
  48. * 0 is the center of the screen, -1 is the top and 1 is the bottom.
  49. */
  50. @serialize()
  51. @uiInput() yOffset = 0
  52. /**
  53. * Autostart after camera stop
  54. */
  55. @serialize()
  56. @uiToggle() autoStart = true
  57. /**
  58. * Time in ms to wait before auto start after the camera stops.
  59. */
  60. @serialize()
  61. @uiInput() autoStartDelay = 30000
  62. /**
  63. * Auto stop on user interaction pointer down or wheel
  64. */
  65. @serialize()
  66. @uiToggle() autoStop = true
  67. /**
  68. * Auto start on scene object load. This requires {@link autoStart} to be true
  69. */
  70. @serialize()
  71. @uiToggle() autoStartOnObjectLoad = true
  72. @serialize()
  73. @uiToggle() autoStartOnObjectLoadDelay = 3000
  74. @uiMonitor() currentTime = 0
  75. @uiMonitor() lastActionTime = Infinity
  76. constructor(enabled = true) {
  77. super()
  78. this.enabled = enabled
  79. }
  80. // private _xDamper = new Damper(50)
  81. /**
  82. * Pointer icon svg
  83. * Note: This is directly added to the DOM
  84. */
  85. @onChange(InteractionPromptPlugin.prototype._pointerIconChanged)
  86. pointerIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="transform: translate(-50%, -25%);" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="36">
  87. <defs>
  88. <path id="A" d="M.001.232h24.997V36H.001z"></path>
  89. </defs>
  90. <g transform="translate(-11 -4)" fill="none" fill-rule="evenodd">
  91. <path fill-opacity="0" fill="#fff" d="M0 0h44v44H0z"></path>
  92. <g transform="translate(11 3)">
  93. <path d="M8.733 11.165c.04-1.108.766-2.027 1.743-2.307a2.54 2.54 0 0 1 .628-.089c.16 0 .314.017.463.044 1.088.2 1.9 1.092 1.9 2.16v8.88h1.26c2.943-1.39 5-4.45 5-8.025a9.01 9.01 0 0 0-1.9-5.56l-.43-.5c-.765-.838-1.683-1.522-2.712-2-1.057-.49-2.226-.77-3.46-.77s-2.4.278-3.46.77c-1.03.478-1.947 1.162-2.71 2l-.43.5a9.01 9.01 0 0 0-1.9 5.56 9.04 9.04 0 0 0 .094 1.305c.03.21.088.41.13.617l.136.624c.083.286.196.56.305.832l.124.333a8.78 8.78 0 0 0 .509.953l.065.122a8.69 8.69 0 0 0 3.521 3.191l1.11.537v-9.178z" fill-opacity=".5" fill="#e4e4e4"></path>
  94. <path d="M22.94 26.218l-2.76 7.74c-.172.485-.676.8-1.253.8H12.24c-1.606 0-3.092-.68-3.98-1.82-1.592-2.048-3.647-3.822-6.11-5.27-.095-.055-.15-.137-.152-.23-.004-.1.046-.196.193-.297.56-.393 1.234-.6 1.926-.6a3.43 3.43 0 0 1 .691.069l4.922.994V10.972c0-.663.615-1.203 1.37-1.203s1.373.54 1.373 1.203v9.882h2.953c.273 0 .533.073.757.21l6.257 3.874c.027.017.045.042.07.06.41.296.586.77.426 1.22M4.1 16.614c-.024-.04-.042-.083-.065-.122a8.69 8.69 0 0 1-.509-.953c-.048-.107-.08-.223-.124-.333l-.305-.832c-.058-.202-.09-.416-.136-.624l-.13-.617a9.03 9.03 0 0 1-.094-1.305c0-2.107.714-4.04 1.9-5.56l.43-.5c.764-.84 1.682-1.523 2.71-2 1.058-.49 2.226-.77 3.46-.77s2.402.28 3.46.77c1.03.477 1.947 1.16 2.712 2l.428.5a9 9 0 0 1 1.901 5.559c0 3.577-2.056 6.636-5 8.026h-1.26v-8.882c0-1.067-.822-1.96-1.9-2.16-.15-.028-.304-.044-.463-.044-.22 0-.427.037-.628.09-.977.28-1.703 1.198-1.743 2.306v9.178l-1.11-.537C6.18 19.098 4.96 18 4.1 16.614M22.97 24.09l-6.256-3.874c-.102-.063-.218-.098-.33-.144 2.683-1.8 4.354-4.855 4.354-8.243 0-.486-.037-.964-.104-1.43a9.97 9.97 0 0 0-1.57-4.128l-.295-.408-.066-.092a10.05 10.05 0 0 0-.949-1.078c-.342-.334-.708-.643-1.094-.922-1.155-.834-2.492-1.412-3.94-1.65l-.732-.088-.748-.03a9.29 9.29 0 0 0-1.482.119c-1.447.238-2.786.816-3.94 1.65a9.33 9.33 0 0 0-.813.686 9.59 9.59 0 0 0-.845.877l-.385.437-.36.5-.288.468-.418.778-.04.09c-.593 1.28-.93 2.71-.93 4.222 0 3.832 2.182 7.342 5.56 8.938l1.437.68v4.946L5 25.64a4.44 4.44 0 0 0-.888-.086c-.017 0-.034.003-.05.003-.252.004-.503.033-.75.08a5.08 5.08 0 0 0-.237.056c-.193.046-.382.107-.568.18-.075.03-.15.057-.225.1-.25.114-.494.244-.723.405a1.31 1.31 0 0 0-.566 1.122 1.28 1.28 0 0 0 .645 1.051C4 29.925 5.96 31.614 7.473 33.563a5.06 5.06 0 0 0 .434.491c1.086 1.082 2.656 1.713 4.326 1.715h6.697c.748-.001 1.43-.333 1.858-.872.142-.18.256-.38.336-.602l2.757-7.74c.094-.26.13-.53.112-.794s-.088-.52-.203-.76a2.19 2.19 0 0 0-.821-.91" fill-opacity=".6" fill="#000"></path>
  95. <path d="M22.444 24.94l-6.257-3.874a1.45 1.45 0 0 0-.757-.211h-2.953v-9.88c0-.663-.616-1.203-1.373-1.203s-1.37.54-1.37 1.203v16.643l-4.922-.994a3.44 3.44 0 0 0-.692-.069 3.35 3.35 0 0 0-1.925.598c-.147.102-.198.198-.194.298.004.094.058.176.153.23 2.462 1.448 4.517 3.22 6.11 5.27.887 1.14 2.373 1.82 3.98 1.82h6.686c.577 0 1.08-.326 1.253-.8l2.76-7.74c.16-.448-.017-.923-.426-1.22-.025-.02-.043-.043-.07-.06z" fill="#fff"></path>
  96. <g transform="translate(0 .769)">
  97. <mask id="B" fill="#fff">
  98. <use xlink:href="#A"></use>
  99. </mask>
  100. <path d="M23.993 24.992a1.96 1.96 0 0 1-.111.794l-2.758 7.74c-.08.22-.194.423-.336.602-.427.54-1.11.87-1.857.872h-6.698c-1.67-.002-3.24-.633-4.326-1.715-.154-.154-.3-.318-.434-.49C5.96 30.846 4 29.157 1.646 27.773c-.385-.225-.626-.618-.645-1.05a1.31 1.31 0 0 1 .566-1.122 4.56 4.56 0 0 1 .723-.405l.225-.1a4.3 4.3 0 0 1 .568-.18l.237-.056c.248-.046.5-.075.75-.08.018 0 .034-.003.05-.003.303-.001.597.027.89.086l3.722.752V20.68l-1.436-.68c-3.377-1.596-5.56-5.106-5.56-8.938 0-1.51.336-2.94.93-4.222.015-.03.025-.06.04-.09.127-.267.268-.525.418-.778.093-.16.186-.316.288-.468.063-.095.133-.186.2-.277L3.773 5c.118-.155.26-.29.385-.437.266-.3.544-.604.845-.877a9.33 9.33 0 0 1 .813-.686C6.97 2.167 8.31 1.59 9.757 1.35a9.27 9.27 0 0 1 1.481-.119 8.82 8.82 0 0 1 .748.031c.247.02.49.05.733.088 1.448.238 2.786.816 3.94 1.65.387.28.752.588 1.094.922a9.94 9.94 0 0 1 .949 1.078l.066.092c.102.133.203.268.295.408a9.97 9.97 0 0 1 1.571 4.128c.066.467.103.945.103 1.43 0 3.388-1.67 6.453-4.353 8.243.11.046.227.08.33.144l6.256 3.874c.37.23.645.55.82.9.115.24.185.498.203.76m.697-1.195c-.265-.55-.677-1.007-1.194-1.326l-5.323-3.297c2.255-2.037 3.564-4.97 3.564-8.114 0-2.19-.637-4.304-1.84-6.114-.126-.188-.26-.37-.4-.552-.645-.848-1.402-1.6-2.252-2.204C15.472.91 13.393.232 11.238.232A10.21 10.21 0 0 0 5.23 2.19c-.848.614-1.606 1.356-2.253 2.205-.136.18-.272.363-.398.55C1.374 6.756.737 8.87.737 11.06c0 4.218 2.407 8.08 6.133 9.842l.863.41v3.092l-2.525-.51c-.356-.07-.717-.106-1.076-.106a5.45 5.45 0 0 0-3.14.996c-.653.46-1.022 1.202-.99 1.983a2.28 2.28 0 0 0 1.138 1.872c2.24 1.318 4.106 2.923 5.543 4.772 1.26 1.62 3.333 2.59 5.55 2.592h6.698c1.42-.001 2.68-.86 3.134-2.138l2.76-7.74c.272-.757.224-1.584-.134-2.325" fill-opacity=".05" fill="#000" mask="url(#B)"></path>
  101. </g>
  102. </g>
  103. </g>
  104. </svg>`
  105. onAdded(viewer: ThreeViewer) {
  106. super.onAdded(viewer)
  107. // legacy, required for files. remove later? todo use OldPluginType
  108. {
  109. if (objectHasOwn(viewer.plugins, 'InteractionPointerPlugin')) {
  110. delete viewer.plugins.InteractionPointerPlugin
  111. }
  112. // eslint-disable-next-line @typescript-eslint/no-this-alias
  113. const p = this
  114. Object.defineProperty(viewer.plugins, 'InteractionPointerPlugin', {
  115. get(): any {
  116. console.warn('InteractionPromptPlugin: PluginType renamed from InteractionPointerPlugin to InteractionPromptPlugin. Please update your code/vjson.')
  117. return p
  118. },
  119. configurable: true, // required to be able to delete
  120. })
  121. }
  122. this.lastActionTime = Infinity
  123. viewer.addEventListener('preFrame', this._preFrame)
  124. viewer.container.addEventListener('pointerdown', this._pointerDown, true) // true is for capturing, this is required to enable orbit controls. https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#capture
  125. viewer.container.addEventListener('wheel', this._pointerDown, true)
  126. viewer.scene.addEventListener('addSceneObject', this._addSceneObject)
  127. viewer.scene.addEventListener('mainCameraUpdate', this._mainCameraUpdate)
  128. this._initializeCursor()
  129. }
  130. onRemove(viewer: ThreeViewer) {
  131. this.stopAnimation()
  132. viewer.removeEventListener('preFrame', this._preFrame)
  133. viewer.container.removeEventListener('pointerdown', this._pointerDown, true)
  134. viewer.container.removeEventListener('wheel', this._pointerDown, true)
  135. viewer.scene.removeEventListener('addSceneObject', this._addSceneObject)
  136. viewer.scene.removeEventListener('mainCameraUpdate', this._mainCameraUpdate)
  137. if (this.cursorEl) {
  138. this.cursorEl.remove()
  139. }
  140. return super.onRemove(viewer)
  141. }
  142. private _mainCameraUpdate = (e: any)=>{
  143. if (this.isDisabled()) return
  144. if (e.change === 'deserialize') {
  145. this.stopAnimation()
  146. this.startAnimation()
  147. } else {
  148. this.lastActionTime = now()
  149. }
  150. }
  151. private _addSceneObject = ()=>{
  152. if (this.autoStartOnObjectLoad) {
  153. this.lastActionTime = now() - this.autoStartDelay + this.autoStartOnObjectLoadDelay
  154. }
  155. }
  156. protected _pointerIconChanged() {
  157. if (!this.cursorEl) return
  158. this.cursorEl.innerHTML = this.pointerIcon
  159. }
  160. private _initializeCursor() {
  161. this.cursorEl = document.createElement('div')
  162. this.cursorEl.style.position = 'absolute'
  163. this.cursorEl.style.top = '0'
  164. this.cursorEl.style.left = '0'
  165. this.cursorEl.style.width = '10px'
  166. this.cursorEl.style.height = '10px'
  167. this.cursorEl.style.opacity = '0'
  168. // this.cursorEl.style.transition = 'opacity 0.25s ease-in-out'
  169. // this.cursorEl.innerHTML = this.pointerIcon
  170. this._pointerIconChanged()
  171. this._viewer!.container.appendChild(this.cursorEl)
  172. }
  173. @serialize()
  174. onlyOnOrbitControls = true
  175. private _orbitWarning = false
  176. @uiButton() startAnimation = () => {
  177. if (!this._viewer || !this.cursorEl || this.isDisabled()) return
  178. if ((this._viewer.scene.mainCamera.controls as OrbitControls3)?.type !== 'OrbitControls' && this.onlyOnOrbitControls) {
  179. if (!this._orbitWarning) console.warn('InteractionPromptPlugin requires OrbitControls, to run anyway, set onlyOnOrbitControls to false')
  180. this._orbitWarning = true
  181. return
  182. }
  183. if (this._viewer.scene.modelRoot.children.length === 0) return
  184. this.currentSphericalPosition = new Spherical().setFromVector3(new Vector3().subVectors(
  185. this._viewer.scene.mainCamera.position,
  186. this._viewer.scene.mainCamera.target
  187. ))
  188. this.cursorEl.style.opacity = '1'
  189. this.currentTime = 0
  190. this.animationRunning = true
  191. this._viewer.scene.mainCamera.setInteractions(false, InteractionPromptPlugin.PluginType)
  192. // if (this._viewer.scene.mainCamera.interactionsEnabled) {
  193. // this.interactionsDisabled = true
  194. // this._viewer.scene.mainCamera.interactionsEnabled = false
  195. // }
  196. }
  197. @uiButton() stopAnimation = () => {
  198. if (!this._viewer || !this.cursorEl) return // dont check for enabled here.
  199. this.animationRunning = false
  200. this.cursorEl.style.opacity = '0'
  201. this._viewer.scene.mainCamera.setInteractions(true, InteractionPromptPlugin.PluginType)
  202. // if (this.interactionsDisabled) {
  203. // this._viewer.scene.mainCamera.interactionsEnabled = true
  204. // this.interactionsDisabled = false
  205. // }
  206. }
  207. private _pointerDown = () => {
  208. if (this.isDisabled()) return
  209. if (this.autoStop) this.stopAnimation()
  210. this.lastActionTime = now()
  211. }
  212. private _x = 0
  213. private _preFrame = async(ev: IEvent<any>) => {
  214. if (!this._viewer || !this.cursorEl) return
  215. if (this.isDisabled() && this.animationRunning) {
  216. this.stopAnimation()
  217. }
  218. if (this.isDisabled()) return
  219. if (!this.animationRunning && this.autoStart && this.lastActionTime + this.autoStartDelay < now())
  220. this.startAnimation()
  221. if (!this.animationRunning) return
  222. if (this.currentTime <= this.animationDuration) {
  223. this.cursorEl.style.opacity = '1'
  224. // this.currentTime = this._xDamper.update(this.currentTime, this.currentTime + ev.deltaTime, 50, 0)
  225. const x = this.currentTime / this.animationDuration
  226. this._x = Math.sin(Math.PI * 2 * x) // this._xDamper.update( this._x,newX , ev.deltaTime , 1)
  227. if (x < 0.25 || x > 0.75) {
  228. this._x *= this._x * Math.sign(this._x)
  229. }
  230. } else {
  231. this.cursorEl.style.opacity = '0'
  232. this._x = 0
  233. }
  234. if (this.currentTime <= this.animationDuration + 50) { // because of precision issues. we need _x to be 0
  235. const sphericalPosition = this.currentSphericalPosition!.clone()
  236. sphericalPosition.theta += this._x * this.rotationDistance
  237. this._viewer.scene.mainCamera.position.setFromSpherical(sphericalPosition).add(this._viewer.scene.mainCamera.target)
  238. this._viewer.scene.mainCamera.setDirty()
  239. }
  240. const canvasBounds = this._viewer.container.getBoundingClientRect()
  241. const cursorX = canvasBounds.width / 2 + -this._x * Math.min(this.animationDistance, canvasBounds.width / 4)
  242. const cursorY = canvasBounds.height / 2 + this.yOffset * canvasBounds.height / 2
  243. this.cursorEl.style.transform = `translate(${Math.floor(cursorX)}px, ${Math.floor(cursorY)}px)`
  244. this.currentTime += ev.deltaTime
  245. if (this.currentTime > this.animationDuration + this.animationPauseDuration) {
  246. this.currentTime = 0
  247. }
  248. }
  249. }