import {EventListener2, Spherical, Vector3} from 'three' import {IEvent, now, objectHasOwn, onChange, serialize} from 'ts-browser-helpers' import {AViewerPluginSync, ThreeViewer} from '../../viewer' import {uiButton, uiFolderContainer, uiInput, uiMonitor, uiToggle} from 'uiconfig.js' import {OrbitControls3} from '../../three' import {IScene, ISceneEventMap} from '../../core' /** * Interaction Prompt Plugin * * A plugin that adds a hand pointer icon over the canvas that moves to prompt the user to interact with the 3d scene. * Pointer icon from [google/model-viewer](https://github.com/google/model-viewer) * * The pointer is automatically shown when some object is in the scene and the camera is not moving. * The animation starts after a delay and stops on user interaction. It then restarts after a delay after the user stops interacting * * The plugin provides several options and functions to configure the automatic behaviour or trigger the animation manually. * TODO - create example * @category Plugins */ @uiFolderContainer('Interaction Prompt') export class InteractionPromptPlugin extends AViewerPluginSync { static readonly PluginType = 'InteractionPromptPlugin' @serialize() @uiToggle() enabled currentSphericalPosition?: Spherical animationRunning = false cursorEl?: HTMLElement // interactionsDisabled = false /** * Animation duration in ms */ @serialize() @uiInput() animationDuration = 2000 /** * Animation distance in pixels */ @serialize() @uiInput() animationDistance = 80 @serialize() @uiInput() animationPauseDuration = 6000 /** * Camera Rotation distance in radians. */ @serialize() @uiInput() rotationDistance = 0.3 /** * Move the pointer icon up or down. * Y offset in the range -1 to 1. * 0 is the center of the screen, -1 is the top and 1 is the bottom. */ @serialize() @uiInput() yOffset = 0 /** * Autostart after camera stop */ @serialize() @uiToggle() autoStart = true /** * Time in ms to wait before auto start after the camera stops. */ @serialize() @uiInput() autoStartDelay = 30000 /** * Auto stop on user interaction pointer down or wheel */ @serialize() @uiToggle() autoStop = true /** * Auto start on scene object load. This requires {@link autoStart} to be true */ @serialize() @uiToggle() autoStartOnObjectLoad = true @serialize() @uiToggle() autoStartOnObjectLoadDelay = 3000 @uiMonitor() currentTime = 0 @uiMonitor() lastActionTime = Infinity constructor(enabled = true) { super() this.enabled = enabled } // private _xDamper = new Damper(50) /** * Pointer icon svg * Note: This is directly added to the DOM */ @onChange(InteractionPromptPlugin.prototype._pointerIconChanged) pointerIcon = ` ` onAdded(viewer: ThreeViewer) { super.onAdded(viewer) // legacy, required for files. remove later? todo use OldPluginType { if (objectHasOwn(viewer.plugins, 'InteractionPointerPlugin')) { delete viewer.plugins.InteractionPointerPlugin } // eslint-disable-next-line @typescript-eslint/no-this-alias const p = this Object.defineProperty(viewer.plugins, 'InteractionPointerPlugin', { get(): any { console.warn('InteractionPromptPlugin: PluginType renamed from InteractionPointerPlugin to InteractionPromptPlugin. Please update your code/vjson.') return p }, configurable: true, // required to be able to delete }) } this.lastActionTime = Infinity viewer.addEventListener('preFrame', this._preFrame) 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 viewer.container.addEventListener('wheel', this._pointerDown, true) viewer.scene.addEventListener('addSceneObject', this._addSceneObject) viewer.scene.addEventListener('mainCameraUpdate', this._mainCameraUpdate) this._initializeCursor() } onRemove(viewer: ThreeViewer) { this.stopAnimation() viewer.removeEventListener('preFrame', this._preFrame) viewer.container.removeEventListener('pointerdown', this._pointerDown, true) viewer.container.removeEventListener('wheel', this._pointerDown, true) viewer.scene.removeEventListener('addSceneObject', this._addSceneObject) viewer.scene.removeEventListener('mainCameraUpdate', this._mainCameraUpdate) if (this.cursorEl) { this.cursorEl.remove() } return super.onRemove(viewer) } private _mainCameraUpdate = (e: any)=>{ if (this.isDisabled()) return if (e.change === 'deserialize' && this.animationRunning) { this.stopAnimation({reset: false}) // reset is false so that the new camera position is not reset this.startAnimation() } else if (this._startedOnce) { this.lastActionTime = now() } } private _startedOnce = false private _addSceneObject: EventListener2<'addSceneObject', ISceneEventMap, IScene> = ()=>{ if (this.autoStartOnObjectLoad && !this._startedOnce) { this.lastActionTime = now() - this.autoStartDelay + this.autoStartOnObjectLoadDelay } } protected _pointerIconChanged() { if (!this.cursorEl) return this.cursorEl.innerHTML = this.pointerIcon } private _initializeCursor() { this.cursorEl = document.createElement('div') this.cursorEl.style.position = 'absolute' this.cursorEl.style.top = '0' this.cursorEl.style.left = '0' this.cursorEl.style.width = '10px' this.cursorEl.style.height = '10px' this.cursorEl.style.opacity = '0' // this.cursorEl.style.transition = 'opacity 0.25s ease-in-out' // this.cursorEl.innerHTML = this.pointerIcon this._pointerIconChanged() this._viewer!.container.appendChild(this.cursorEl) } @serialize() onlyOnOrbitControls = true private _orbitWarning = false @uiButton() startAnimation = () => { if (!this._viewer || !this.cursorEl || this.isDisabled()) return if ((this._viewer.scene.mainCamera.controls as OrbitControls3)?.type !== 'OrbitControls' && this.onlyOnOrbitControls) { if (!this._orbitWarning) console.warn('InteractionPromptPlugin requires OrbitControls, to run anyway, set onlyOnOrbitControls to false') this._orbitWarning = true return } if (this._viewer.scene.modelRoot.children.length === 0) return this.currentSphericalPosition = new Spherical().setFromVector3(new Vector3().subVectors( this._viewer.scene.mainCamera.position, this._viewer.scene.mainCamera.target )) this.cursorEl.style.opacity = '1' this.currentTime = 0 this.animationRunning = true this._startedOnce = true this._viewer.scene.mainCamera.setInteractions(false, InteractionPromptPlugin.PluginType) // if (this._viewer.scene.mainCamera.interactionsEnabled) { // this.interactionsDisabled = true // this._viewer.scene.mainCamera.interactionsEnabled = false // } } @uiButton() stopAnimation = async({reset = true}: {reset?: boolean} = {}) => { if (!this._viewer || !this.cursorEl) return // dont check for enabled here. this.animationRunning = false this.cursorEl.style.opacity = '0' if (this.currentSphericalPosition && reset) { this._viewer.scene.mainCamera.position.setFromSpherical(this.currentSphericalPosition).add(this._viewer.scene.mainCamera.target) this._viewer.scene.mainCamera.setDirty() this.currentSphericalPosition = undefined } this._viewer.scene.mainCamera.setInteractions(true, InteractionPromptPlugin.PluginType) // if (this.interactionsDisabled) { // this._viewer.scene.mainCamera.interactionsEnabled = true // this.interactionsDisabled = false // } return this._viewer.doOnce('postFrame') } private _pointerDown = () => { if (this.isDisabled()) return if (this.autoStop) this.stopAnimation({reset: false}) // todo dont reset only on pointer drag, not down this.lastActionTime = now() } private _x = 0 private _preFrame = async(ev: IEvent) => { if (!this._viewer || !this.cursorEl) return if (this.isDisabled() && this.animationRunning) { this.stopAnimation() } if (this.isDisabled()) return if (!this.animationRunning && this.autoStart && this.lastActionTime + this.autoStartDelay < now()) this.startAnimation() if (!this.animationRunning) return if (this.currentTime <= this.animationDuration) { this.cursorEl.style.opacity = '1' // this.currentTime = this._xDamper.update(this.currentTime, this.currentTime + ev.deltaTime, 50, 0) const x = this.currentTime / this.animationDuration this._x = Math.sin(Math.PI * 2 * x) // this._xDamper.update( this._x,newX , ev.deltaTime , 1) if (x < 0.25 || x > 0.75) { this._x *= this._x * Math.sign(this._x) } } else { this.cursorEl.style.opacity = '0' this._x = 0 } if (this.currentTime <= this.animationDuration + 50) { // because of precision issues. we need _x to be 0 const sphericalPosition = this.currentSphericalPosition!.clone() sphericalPosition.theta += this._x * this.rotationDistance this._viewer.scene.mainCamera.position.setFromSpherical(sphericalPosition).add(this._viewer.scene.mainCamera.target) this._viewer.scene.mainCamera.setDirty() } const canvasBounds = this._viewer.container.getBoundingClientRect() const cursorX = canvasBounds.width / 2 + -this._x * Math.min(this.animationDistance, canvasBounds.width / 4) const cursorY = canvasBounds.height / 2 + this.yOffset * canvasBounds.height / 2 this.cursorEl.style.transform = `translate(${Math.floor(cursorX)}px, ${Math.floor(cursorY)}px)` this.currentTime += ev.deltaTime if (this.currentTime > this.animationDuration + this.animationPauseDuration) { this.currentTime = 0 } } }