| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584 |
- import {Object3D, Vector3} from 'three'
- import {Easing} from 'popmotion'
- import {AViewerPluginSync, ThreeViewer} from '../../viewer'
- import {Box3B} from '../../three'
- import {onChange, serialize, timeout} from 'ts-browser-helpers'
- import {generateUiConfig, uiButton, uiDropdown, uiInput, UiObjectConfig, uiSlider, uiToggle} from 'uiconfig.js'
- import {EasingFunctions, EasingFunctionType} from '../../utils'
- import {CameraView, ICamera, ICameraView} from '../../core'
- import {AnimationResult, PopmotionPlugin} from './PopmotionPlugin'
- import {InteractionPromptPlugin} from '../interaction/InteractionPromptPlugin'
- import {getFittingDistance} from '../../three/utils/camera'
-
- export interface CameraViewPluginOptions{duration?: number, ease?: EasingFunctionType, interpolateMode?: 'spherical'|'linear'}
-
- /**
- * Camera View Plugin
- *
- * Provides API to save, interact and animate and loop between with multiple camera states/views using the {@link PopmotionPlugin}.
- *
- */
- export class CameraViewPlugin extends AViewerPluginSync<'viewChange'|'startViewChange'|'viewAdd'|'viewDelete'> {
- static readonly PluginType = 'CameraViews'
-
- enabled = true
-
- // get dirty() { // todo: issue with recorder convergeMode?
- // return this._animating
- // }
-
- constructor(options: CameraViewPluginOptions = {}) {
- super()
- this.addCurrentView = this.addCurrentView.bind(this)
- this.resetToFirstView = this.resetToFirstView.bind(this)
- this.animateAllViews = this.animateAllViews.bind(this)
- // this.recordAllViews = this.recordAllViews.bind(this)
- // this._wheel = this._wheel.bind(this)
- // this._pointerMove = this._pointerMove.bind(this)
- // this._postFrame = this._postFrame.bind(this)
-
- this.animDuration = options.duration ?? this.animDuration
- this.animEase = options.ease ?? this.animEase
- this.interpolateMode = options.interpolateMode ?? this.interpolateMode
- }
-
-
-
- @serialize('cameraViews')
- private _cameraViews: CameraView[] = []
- get cameraViews(): CameraView[] {
- return this._cameraViews
- }
- get camViews(): CameraView[] {
- return this._cameraViews
- }
-
- @onChange(CameraViewPlugin.prototype._animationLoop)
- /**
- * Loop all views indefinitely.
- */
- @serialize() @uiToggle('Loop All Views') viewLooping = false
- /**
- * Pauses time between view changes when animating all views or looping.
- */
- @serialize() @uiInput('View Pause Time') viewPauseTime = 200
-
- /**
- * {@link EasingFunctions}
- */
- @serialize() @uiDropdown('Ease', Object.keys(EasingFunctions).map((label:string)=>({label}))) animEase: EasingFunctionType = 'easeInOutSine' // ms
- @serialize() @uiSlider('Duration', [10, 10000], 10) animDuration = 1000 // ms
- @serialize() @uiDropdown('Interpolation', ['spherical', 'linear'].map((label:string)=>({label})))
- interpolateMode: 'spherical'|'linear' = 'spherical'
-
-
- // not used
- @serialize()
- // @uiSlider('RotationOffset', [0.2, 0.75], 0.01)
- rotationOffset = 0.25
-
- private _animating = false
- get animating(): boolean {
- return this._animating
- }
-
- dependencies = [PopmotionPlugin]
-
- // private _updaters: {u: ((timestamp: number) => void), time: number}[] = []
- // private _lastFrameTime = 0 // for post frame
-
- onAdded(viewer: ThreeViewer): void {
- super.onAdded(viewer)
-
- // todo: move to PopmotionPlugin
- // todo: remove event listener
- viewer.addEventListener('preFrame', (_: any)=>{
- // console.log(ev.deltaTime)
-
- // this._updaters.forEach(u=>{
- // let dt = ev.deltaTime
- // if (u.time + dt < 0) dt = -u.time
- // u.time += dt
- // if (Math.abs(dt) > 0.001)
- // u.u(dt)
- // })
-
- })
-
- // viewer.addEventListener('postFrame', this._postFrame)
- // window.addEventListener('wheel', this._wheel)
- // window.addEventListener('pointermove', this._pointerMove)
-
- }
-
- onRemove(viewer: ThreeViewer): void {
-
- // viewer.removeEventListener('postFrame', this._postFrame)
- // window.removeEventListener('wheel', this._wheel)
- // window.removeEventListener('pointermove', this._pointerMove)
-
- return super.onRemove(viewer)
- }
-
- @uiButton('Reset To First View', {sendArgs: false})
- public async resetToFirstView(duration = 100) {
- if (this.isDisabled()) return
- this._currentView = undefined
- await this.animateToView(0, duration)
- await timeout(2)
- }
-
- @uiButton('Add Current View')
- async addCurrentView() {
- if (this.isDisabled()) return
- const camera = this._viewer?.scene.mainCamera
- if (!camera) return
- const view = this.getView(camera)
- this.addView(view)
- view.name = 'View ' + this._cameraViews.length
- return view
- }
-
- addView(view: CameraView) {
- if (!this._cameraViews.includes(view)) this._cameraViews.push(view)
- view.addEventListener('setView', this._viewSetView as any)
- view.addEventListener('updateView', this._viewUpdateView as any)
- view.addEventListener('deleteView', this._viewDeleteView as any)
- view.addEventListener('animateView', this._viewAnimateView as any)
- this.uiConfig.uiRefresh?.()
- this.dispatchEvent({type: 'viewAdd', view})
- }
-
- protected _viewSetView = ({view, camera}: {view?: CameraView, camera?: ICamera}) => {
- if (!view) {
- this._viewer?.console.warn('Invalid view', view)
- return
- }
- this.setView(view, camera)
- }
-
- protected _viewUpdateView = ({view, camera}: {view: CameraView, camera?: ICamera}) => {
- if (!view) {
- this._viewer?.console.warn('Invalid view', view)
- return
- }
- const name = view.name
- this.getView(camera, view.isWorldSpace ?? true, view)
- view.name = name
- }
-
- protected _viewDeleteView = ({view}: {view: CameraView}) => {
- if (!view) {
- this._viewer?.console.warn('Invalid view', view)
- return
- }
- this.deleteView(view)
- }
-
- protected _viewAnimateView = async({view, camera, duration, easing, throwOnStop}: {view: CameraView, camera?: ICamera, duration?: number, easing?: Easing|EasingFunctionType, throwOnStop?: boolean}) => {
- if (!view) {
- this._viewer?.console.warn('Invalid view', view)
- return
- }
- return this.animateToView(view, duration || this.animDuration, easing || this.animEase, camera, throwOnStop)
- }
-
- deleteView(view: CameraView) {
- const i = this._cameraViews.indexOf(view)
- if (i >= 0)
- this._cameraViews.splice(i, 1)
- view.removeEventListener('setView', this._viewSetView as any)
- view.removeEventListener('updateView', this._viewUpdateView as any)
- view.removeEventListener('deleteView', this._viewDeleteView as any)
- view.removeEventListener('animateView', this._viewAnimateView as any)
- this.uiConfig.uiRefresh?.()
- this.dispatchEvent({type: 'viewDelete', view})
- }
-
- getView(camera?: ICamera, worldSpace = true, view?: CameraView) {
- camera = camera || this._viewer?.scene.mainCamera
- if (!camera) return view ?? new CameraView()
- return camera.getView(worldSpace, view)
- }
-
- setView(view: ICameraView, camera?: ICamera) {
- camera = camera || this._viewer?.scene.mainCamera
- if (!camera) return
- camera.setView(view)
- }
-
- private _currentView: CameraView | undefined
-
- @uiButton('Focus Next', {sendArgs: false}) focusNext = (wrap = true)=>{
- if (this._animating) return
- if (this._cameraViews.length < 2) return
- let index = this._cameraViews.findIndex(v=>v === this._currentView)
- if (index < 0) index = -1 // first view
- index = index + 1
- if (!wrap) index = Math.min(index, this._cameraViews.length - 1)
- else index = index % this._cameraViews.length
- this.animateToView(index)
- }
- @uiButton('Focus Previous', {sendArgs: false}) focusPrevious = (wrap = true)=> {
- if (this._animating) return
- if (this._cameraViews.length < 2 || !this._currentView) return
- let index = this._cameraViews.findIndex(v=>v === this._currentView)
- if (index < 0) index = 0 // last view
- index = index - 1
- if (!wrap) index = Math.max(index, 0)
- else index = (index + this._cameraViews.length) % this._cameraViews.length
- this.animateToView(index)
- }
-
- private _popAnimations: AnimationResult[] = []
-
- async animateToView(_view: CameraView|number|string, duration?: number, easing?: Easing|EasingFunctionType, camera?: ICamera, throwOnStop = false) {
- camera = camera || this._viewer?.scene.mainCamera
- if (!camera) return
- // if (this._currentView === view) return // todo: also check if the camera is at the correct position and orientation, till then use resetToFirstView to reset current view
- if (this._animating) {
- this._popAnimations.forEach(a=>a?.stop && a.stop()) // don't call stopAllAnimations here, as it sets viewLooping to false and changes config.
- this._popAnimations = []
- let i = 0
- while (this._animating) {
- await timeout(100)
- if (i++ > 20) { // 2s timeout
- break
- }
- }
- if (this._animating) {
- console.warn('Unable to stop all animations, maybe because of viewLooping?')
- return
- }
- }
- const view = typeof _view === 'number' ? this._cameraViews[_view] :
- typeof _view === 'string' ? this._cameraViews.find(v=>v.name === _view) :
- _view
- if (!view) {
- this._viewer?.console.warn('Invalid view', _view)
- return
- }
-
- const interactionPrompt = this._viewer?.getPlugin(InteractionPromptPlugin)
- if (interactionPrompt && interactionPrompt.animationRunning) {
- await interactionPrompt.stopAnimation({reset: true})
- }
-
- this._currentView = view
- this._animating = true
-
- this._viewer?.scene.mainCamera.setInteractions(false, CameraViewPlugin.PluginType) // todo: also for seekOnScroll
-
- this.dispatchEvent({type: 'startViewChange', view})
-
- const popmotion = this._viewer?.getPlugin(PopmotionPlugin)
- if (!popmotion) throw new Error('PopmotionPlugin not found')
-
- if (duration === undefined) duration = this.animDuration
- const ease: any = (typeof easing === 'function' ? easing : EasingFunctions[easing || this.animEase]) as (x: number) => number
- // const ease = (x:number)=>x
- // const driver = this._driver
- this._popAnimations = []
- await popmotion.animateCameraAsync(camera, view, this.interpolateMode === 'spherical', {ease, duration}, this._popAnimations)
- .catch((e)=>{
- // console.error(e)
- if (throwOnStop) throw e
- })
-
- this._viewer?.scene.mainCamera.setInteractions(true, CameraViewPlugin.PluginType)
- this._animating = false
-
- this._viewer?.setDirty()
-
- this.dispatchEvent({type: 'viewChange', view})
-
- await timeout(10)
- }
-
- @uiButton('Animate All Views')
- async animateAllViews() {
- if (this.isDisabled()) return
- if (this.viewLooping || this._cameraViews.length < 2) return
- while (this._viewQueue.length > 0) this._viewQueue.pop()
- this._viewQueue.push(...this._cameraViews)
- this._viewQueue.push(this._viewQueue.shift()!)
- this._infiniteLooping = false
- await this._animationLoop()
- this._infiniteLooping = true
- }
-
- @uiButton('Stop All Animations')
- async stopAllAnimations() {
- this.viewLooping = false
- this._popAnimations.forEach(a => a?.stop?.())
- this._popAnimations = []
- while (this._animating || this._animationLooping) {
- await timeout(100)
- }
- }
-
- fromJSON(data: any, meta?: any): this | null {
- this._cameraViews.forEach(v=>this.deleteView(v)) // deserialize pushes to the existing array
- if (super.fromJSON(data, meta)) {
- this._cameraViews.forEach(v=>this.addView(v))
- this.uiConfig.uiRefresh?.()
- return this
- }
- return null
- }
-
- public async animateToObject(selected?: Object3D, distanceMultiplier = 4, duration?: number, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 5.0}) {
- if (!this._viewer) return
- const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot, false, true)
- const center = bbox.getCenter(new Vector3())
- const size = bbox.getSize(new Vector3())
- const radius = size.length() / 2
- await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, radius * distanceMultiplier)), center, duration, ease)
- }
-
- public async animateToFitObject(selected?: Object3D, distanceMultiplier = 1.5, duration = 1000, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 50.0}) {
- if (!this._viewer) return
- const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot, false, true)
- const cameraZ = getFittingDistance(this._viewer.scene.mainCamera, bbox)
- const center = bbox.getCenter(new Vector3()) // world position
- await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, cameraZ * distanceMultiplier)), center, duration, ease)
- }
-
- /**
- *
- * @param distanceFromTarget - in world units
- * @param center - target (center) of the view in world coordinates
- * @param duration - in milliseconds
- * @param ease
- */
- public async animateToTarget(distanceFromTarget: number, center: Vector3, duration?: number, ease?: Easing|EasingFunctionType) {
- const view = this.getView() // world space
- view.target.copy(center)
- const direction = new Vector3().subVectors(view.target, view.position).normalize()
- view.position.copy(direction.multiplyScalar(-distanceFromTarget).add(view.target))
- await this.animateToView(view, duration, ease)
- }
-
- uiConfig: UiObjectConfig = {
- type: 'folder',
- label: 'Camera Views',
- // expanded: true,
- children: [
- ()=>[...this._cameraViews.map(view => view.uiConfig)],
- ...generateUiConfig(this) || [],
- ],
- }
-
- get animationLooping(): boolean {
- return this._animationLooping
- }
- private _viewQueue: CameraView[] = []
- private _animationLooping = false
- private _infiniteLooping = true
- private async _animationLoop() {
- if (this._animationLooping) return
- this._animationLooping = true
- while (this.viewLooping || !this._infiniteLooping) {
- if (this.isDisabled()) break
- if (this._cameraViews.length < 1) break
- if (this._viewQueue.length === 0) {
- if (this._infiniteLooping) this._viewQueue.push(...this._cameraViews)
- else break
- }
- await this.animateToView(this._viewQueue.shift()!)
- await timeout(2 + this.viewPauseTime) // ms delay
- }
- this._animationLooping = false
- }
-
- // region deprecated
-
- /**
- * @deprecated - renamed to {@link getView} or {@link ICamera.getView}
- * @param camera
- * @param worldSpace
- */
- getCurrentCameraView(camera?: ICamera, worldSpace = true) {
- return this.getView(camera, worldSpace)
- }
-
- /**
- * @deprecated - renamed to {@link setView} or {@link ICamera.setView}
- * @param view
- */
- setCurrentCameraView(view: CameraView) {
- return this.setView(view)
- }
-
-
- /**
- * @deprecated - use {@link animateToView} instead
- * @param view
- */
- async focusView(view: CameraView) {
- return this.animateToView(view)
- }
-
- // endregion
-
- // region to be ported to other plugins
-
- // /**
- // * For slight rotation of camera when seekOnScroll is enabled
- // */
- // private _pointerMove(ev: PointerEvent) {
- // if (this.isDisabled()) return
- // if (!this._animating && this.seekOnScroll) {
- // const cam = this._viewer?.scene.mainCamera
- // if (!cam) return
- // const s = new Spherical()
- // const p = cam.position
- // const t = cam.target
- // const q = new Quaternion().setFromUnitVectors(cam.cameraObject.up, new Vector3(0, 1, 0))
- // const qi = q.clone().invert()
- // const offset = p.clone().sub(t)
- // offset.applyQuaternion(q)
- // s.setFromVector3(offset)
- // s.theta += this.rotationOffset * ev.movementX / this._viewer!.canvas!.clientWidth
- // s.phi += this.rotationOffset * ev.movementY / this._viewer!.canvas!.clientHeight
- // s.makeSafe()
- // offset.setFromSpherical(s)
- // offset.applyQuaternion(qi)
- // p.copy(t).add(offset)
- // cam.setDirty()
- // }
- // }
-
- // // @uiToggle() @serialize()
- // animateOnScroll = false // buggy
- //
- // @uiToggle() @serialize()
- // seekOnScroll = false
-
- // private _scrollAnimationState = 0
- // scrollAnimationDamping = 0.1
- // private _wheel(ev: any | WheelEvent) {
- // if (this.isDisabled()) return
- // if (this.seekOnScroll && !this._animating) {
- // // if (ev.deltaY > 0) this.focusNext(false)
- // // else this.focusPrevious(false)
- // } else if (Math.abs(ev.deltaY) > 0.001) {
- // this._scrollAnimationState = -1. * Math.sign(ev.deltaY)
- // }
- // }
-
-
- // private _driver: Driver = (update)=>{
- // return {
- // start: ()=>this._updaters.push({u:update, time:0}),
- // stop: ()=> this._updaters.splice(this._updaters.findIndex(u=>u.u === update), 1),
- // }
- // }
-
- // private _fadeDisabled = false
-
- // todo: same code used in PopmotionPlugin, merge somehow
- // private _postFrame() {
- // if (!this._viewer) return
- // if (this.isDisabled() || !this._animating) {
- // this._lastFrameTime = 0
- // if (this._fadeDisabled) {
- // this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')?.enable(CameraViewPlugin.PluginType)
- // this._fadeDisabled = false
- // }
- // // console.log('not anim')
- // return
- // }
- // const time = now() / 1000.0
- // if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 60.0
- // let delta = time - this._lastFrameTime
- // this._lastFrameTime = time
- // delta = delta * (this.animateOnScroll ? this._scrollAnimationState : 1)
- //
- // const d = this._viewer.getPluginByType<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta()
- // if (d && d > 0) delta = d
- // if (d === 0) return // not converged yet.
- // // if d < 0: not recording, do nothing
- //
- // delta *= 1000
- //
- // // delta = 16.666
- //
- // // console.log(delta)
- // // console.log(dt)
- // //
- //
- // if (delta <= 0) return
- //
- // this._updaters.forEach(u=>{
- // let dt = delta
- // if (u.time + dt < 0) dt = -u.time
- // u.time += dt
- // if (Math.abs(dt) > 0.001)
- // u.u(dt)
- // })
- // if (this._scrollAnimationState < 0.001) this._scrollAnimationState = 0
- // else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping
- //
- // if (!this._fadeDisabled) {
- // const ff = this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')
- // if (ff) {
- // ff.disable(CameraViewPlugin.PluginType)
- // this._fadeDisabled = true
- // }
- // }
- // }
-
- // @uiButton('Record All Views')
- // public async recordAllViews(onStart?: ()=>void, downloadOnEnd = true) {
- // if (this.isDisabled()) return
- // const recorder = this._viewer?.getPluginByType<CanvasRecorderPlugin>('CanvasRecorder')
- // if (!recorder || !recorder.enabled) return
- // if (this._cameraViews.length < 1) return
- // await this.resetToFirstView()
- // if (recorder.isRecording()) {
- // console.error('CanvasRecorderPlugin is already recording')
- // return
- // }
- // return new Promise<Blob|undefined>((resolve, reject) => {
- // const listener2 = ()=>{
- // recorder.removeEventListener('start', listenerStart)
- // recorder.removeEventListener('stop', listener2)
- // recorder.removeEventListener('error', listenerError)
- // }
- // const listenerStart = async() => {
- // listener2()
- // onStart?.()
- // await this.animateAllViews()
- // const blob = await recorder.stopRecording()
- // if (downloadOnEnd) {
- // const name = await this._viewer?.prompt('Canvas Recorder: Save file as', 'recording.mp4')
- // if (name !== null && blob) await this._downloadBlob(blob, name || 'recording.mp4')
- // }
- // resolve(blob)
- // }
- // const listenerError = async() => {
- // listener2()
- // reject()
- // }
- // recorder.addEventListener('start', listenerStart)
- // recorder.addEventListener('stop', listener2)
- // recorder.addEventListener('error', listenerError)
- // if (!recorder.startRecording()) {
- // console.error('cannot start recording')
- // return
- // }
- // })
- // }
-
- // private async _downloadBlob(blob: Blob, name: string) {
- // const tr = this._viewer?.getPluginByType<FileTransferPlugin>('FileTransferPlugin')
- // if (!tr) {
- // this._viewer?.console.error('FileTransferPlugin required to export/download file')
- // return
- // }
- // await tr.exportFile(blob, name)
- // }
-
- // endregion
- }
|