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
}
}
}