threepipe
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

CameraViewPlugin.ts 22KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. import {Object3D, Vector3} from 'three'
  2. import {Easing} from 'popmotion'
  3. import {AViewerPluginSync, ThreeViewer} from '../../viewer'
  4. import {Box3B} from '../../three'
  5. import {onChange, serialize, timeout} from 'ts-browser-helpers'
  6. import {generateUiConfig, uiButton, uiDropdown, uiInput, UiObjectConfig, uiSlider, uiToggle} from 'uiconfig.js'
  7. import {EasingFunctions, EasingFunctionType} from '../../utils'
  8. import {CameraView, ICamera, ICameraView, PerspectiveCamera2} from '../../core'
  9. import {AnimationResult, PopmotionPlugin} from './PopmotionPlugin'
  10. export interface CameraViewPluginOptions{duration?: number, ease?: EasingFunctionType, interpolateMode?: 'spherical'|'linear'}
  11. /**
  12. * Camera View Plugin
  13. *
  14. * Provides API to save, interact and animate and loop between with multiple camera states/views using the {@link PopmotionPlugin}.
  15. *
  16. */
  17. export class CameraViewPlugin extends AViewerPluginSync<'viewChange'|'startViewChange'|'viewAdd'|'viewDelete'> {
  18. static readonly PluginType = 'CameraViews'
  19. enabled = true
  20. // get dirty() { // todo: issue with recorder convergeMode?
  21. // return this._animating
  22. // }
  23. constructor(options: CameraViewPluginOptions = {}) {
  24. super()
  25. this.addCurrentView = this.addCurrentView.bind(this)
  26. this.resetToFirstView = this.resetToFirstView.bind(this)
  27. this.animateAllViews = this.animateAllViews.bind(this)
  28. // this.recordAllViews = this.recordAllViews.bind(this)
  29. // this._wheel = this._wheel.bind(this)
  30. // this._pointerMove = this._pointerMove.bind(this)
  31. // this._postFrame = this._postFrame.bind(this)
  32. this.animDuration = options.duration ?? this.animDuration
  33. this.animEase = options.ease ?? this.animEase
  34. this.interpolateMode = options.interpolateMode ?? this.interpolateMode
  35. }
  36. @serialize('cameraViews')
  37. private _cameraViews: CameraView[] = []
  38. get cameraViews(): CameraView[] {
  39. return this._cameraViews
  40. }
  41. get camViews(): CameraView[] {
  42. return this._cameraViews
  43. }
  44. @onChange(CameraViewPlugin.prototype._animationLoop)
  45. /**
  46. * Loop all views indefinitely.
  47. */
  48. @serialize() @uiToggle('Loop All Views') viewLooping = false
  49. /**
  50. * Pauses time between view changes when animating all views or looping.
  51. */
  52. @serialize() @uiInput('View Pause Time') viewPauseTime = 200
  53. /**
  54. * {@link EasingFunctions}
  55. */
  56. @serialize() @uiDropdown('Ease', Object.keys(EasingFunctions).map((label:string)=>({label}))) animEase: EasingFunctionType = 'easeInOutSine' // ms
  57. @serialize() @uiSlider('Duration', [10, 10000], 10) animDuration = 1000 // ms
  58. @serialize() @uiDropdown('Interpolation', ['spherical', 'linear'].map((label:string)=>({label})))
  59. interpolateMode: 'spherical'|'linear' = 'spherical'
  60. // not used
  61. @serialize()
  62. // @uiSlider('RotationOffset', [0.2, 0.75], 0.01)
  63. rotationOffset = 0.25
  64. private _animating = false
  65. get animating(): boolean {
  66. return this._animating
  67. }
  68. dependencies = [PopmotionPlugin]
  69. // private _updaters: {u: ((timestamp: number) => void), time: number}[] = []
  70. // private _lastFrameTime = 0 // for post frame
  71. onAdded(viewer: ThreeViewer): void {
  72. super.onAdded(viewer)
  73. // todo: move to PopmotionPlugin
  74. // todo: remove event listener
  75. viewer.addEventListener('preFrame', (_: any)=>{
  76. // console.log(ev.deltaTime)
  77. // this._updaters.forEach(u=>{
  78. // let dt = ev.deltaTime
  79. // if (u.time + dt < 0) dt = -u.time
  80. // u.time += dt
  81. // if (Math.abs(dt) > 0.001)
  82. // u.u(dt)
  83. // })
  84. })
  85. // viewer.addEventListener('postFrame', this._postFrame)
  86. // window.addEventListener('wheel', this._wheel)
  87. // window.addEventListener('pointermove', this._pointerMove)
  88. }
  89. onRemove(viewer: ThreeViewer): void {
  90. // viewer.removeEventListener('postFrame', this._postFrame)
  91. // window.removeEventListener('wheel', this._wheel)
  92. // window.removeEventListener('pointermove', this._pointerMove)
  93. return super.onRemove(viewer)
  94. }
  95. @uiButton('Reset To First View')
  96. public async resetToFirstView(duration = 100) {
  97. if (this.isDisabled()) return
  98. this._currentView = undefined
  99. await this.animateToView(0, duration)
  100. await timeout(2)
  101. }
  102. @uiButton('Add Current View')
  103. async addCurrentView() {
  104. if (this.isDisabled()) return
  105. const camera = this._viewer?.scene.mainCamera
  106. if (!camera) return
  107. const view = this.getView(camera)
  108. this.addView(view)
  109. view.name = 'View ' + this._cameraViews.length
  110. return view
  111. }
  112. addView(view: CameraView) {
  113. this._cameraViews.push(view)
  114. view.addEventListener('setView', this._viewSetView as any)
  115. view.addEventListener('updateView', this._viewUpdateView as any)
  116. view.addEventListener('deleteView', this._viewDeleteView as any)
  117. view.addEventListener('animateView', this._viewAnimateView as any)
  118. this.uiConfig.uiRefresh?.()
  119. this.dispatchEvent({type: 'viewAdd', view})
  120. }
  121. protected _viewSetView = ({view, camera}: {view?: CameraView, camera?: ICamera}) => {
  122. if (!view) {
  123. this._viewer?.console.warn('Invalid view', view)
  124. return
  125. }
  126. this.setView(view, camera)
  127. }
  128. protected _viewUpdateView = ({view, camera}: {view: CameraView, camera?: ICamera}) => {
  129. if (!view) {
  130. this._viewer?.console.warn('Invalid view', view)
  131. return
  132. }
  133. const name = view.name
  134. this.getView(camera, view.isWorldSpace ?? true, view)
  135. view.name = name
  136. }
  137. protected _viewDeleteView = ({view}: {view: CameraView}) => {
  138. if (!view) {
  139. this._viewer?.console.warn('Invalid view', view)
  140. return
  141. }
  142. this.deleteView(view)
  143. }
  144. protected _viewAnimateView = async({view, camera, duration, easing, throwOnStop}: {view: CameraView, camera?: ICamera, duration?: number, easing?: Easing|EasingFunctionType, throwOnStop?: boolean}) => {
  145. if (!view) {
  146. this._viewer?.console.warn('Invalid view', view)
  147. return
  148. }
  149. return this.animateToView(view, duration || this.animDuration, easing || this.animEase, camera, throwOnStop)
  150. }
  151. deleteView(view: CameraView) {
  152. const i = this._cameraViews.indexOf(view)
  153. if (i >= 0)
  154. this._cameraViews.splice(i, 1)
  155. this.uiConfig.uiRefresh?.()
  156. this.dispatchEvent({type: 'viewDelete', view})
  157. }
  158. getView(camera?: ICamera, worldSpace = true, view?: CameraView) {
  159. camera = camera || this._viewer?.scene.mainCamera
  160. if (!camera) return view ?? new CameraView()
  161. return camera.getView(worldSpace, view)
  162. }
  163. setView(view: ICameraView, camera?: ICamera) {
  164. camera = camera || this._viewer?.scene.mainCamera
  165. if (!camera) return
  166. camera.setView(view)
  167. }
  168. private _currentView: CameraView | undefined
  169. @uiButton('Focus Next') focusNext = (wrap = true)=>{
  170. if (this._animating) return
  171. if (this._cameraViews.length < 2) return
  172. let index = this._cameraViews.findIndex(v=>v === this._currentView)
  173. if (index < 0) index = -1 // first view
  174. index = index + 1
  175. if (!wrap) index = Math.min(index, this._cameraViews.length - 1)
  176. else index = index % this._cameraViews.length
  177. this.animateToView(index)
  178. }
  179. @uiButton('Focus Previous') focusPrevious = (wrap = true)=> {
  180. if (this._animating) return
  181. if (this._cameraViews.length < 2 || !this._currentView) return
  182. let index = this._cameraViews.findIndex(v=>v === this._currentView)
  183. if (index < 0) index = 0 // last view
  184. index = index - 1
  185. if (!wrap) index = Math.max(index, 0)
  186. else index = (index + this._cameraViews.length) % this._cameraViews.length
  187. this.animateToView(index)
  188. }
  189. private _popAnimations: AnimationResult[] = []
  190. async animateToView(_view: CameraView|number, duration?: number, easing?: Easing|EasingFunctionType, camera?: ICamera, throwOnStop = false) {
  191. camera = camera || this._viewer?.scene.mainCamera
  192. if (!camera) return
  193. // 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
  194. if (this._animating) {
  195. this._popAnimations.forEach(a=>a?.stop && a.stop()) // don't call stopAllAnimations here, as it sets viewLooping to false and changes config.
  196. this._popAnimations = []
  197. let i = 0
  198. while (this._animating) {
  199. await timeout(100)
  200. if (i++ > 20) { // 2s timeout
  201. break
  202. }
  203. }
  204. if (this._animating) {
  205. console.warn('Unable to stop all animations, maybe because of viewLooping?')
  206. return
  207. }
  208. }
  209. const view = typeof _view === 'number' ? this._cameraViews[_view] : _view
  210. this._currentView = view
  211. this._animating = true
  212. this._viewer?.scene.mainCamera.setInteractions(false, CameraViewPlugin.PluginType) // todo: also for seekOnScroll
  213. this.dispatchEvent({type: 'startViewChange', view})
  214. const popmotion = this._viewer?.getPlugin(PopmotionPlugin)
  215. if (!popmotion) throw new Error('PopmotionPlugin not found')
  216. if (duration === undefined) duration = this.animDuration
  217. const ease: any = (typeof easing === 'function' ? easing : EasingFunctions[easing || this.animEase]) as (x: number) => number
  218. // const ease = (x:number)=>x
  219. // const driver = this._driver
  220. this._popAnimations = []
  221. await popmotion.animateCameraAsync(camera, view, this.interpolateMode === 'spherical', {ease, duration}, this._popAnimations)
  222. .catch((e)=>{
  223. // console.error(e)
  224. if (throwOnStop) throw e
  225. })
  226. this._viewer?.scene.mainCamera.setInteractions(true, CameraViewPlugin.PluginType)
  227. this._animating = false
  228. this._viewer?.setDirty()
  229. this.dispatchEvent({type: 'viewChange', view})
  230. await timeout(10)
  231. }
  232. @uiButton('Animate All Views')
  233. async animateAllViews() {
  234. if (this.isDisabled()) return
  235. if (this.viewLooping || this._cameraViews.length < 2) return
  236. while (this._viewQueue.length > 0) this._viewQueue.pop()
  237. this._viewQueue.push(...this._cameraViews)
  238. this._viewQueue.push(this._viewQueue.shift()!)
  239. this._infiniteLooping = false
  240. await this._animationLoop()
  241. this._infiniteLooping = true
  242. }
  243. @uiButton('Stop All Animations')
  244. async stopAllAnimations() {
  245. this.viewLooping = false
  246. this._popAnimations.forEach(a => a?.stop?.())
  247. this._popAnimations = []
  248. while (this._animating || this._animationLooping) {
  249. await timeout(100)
  250. }
  251. }
  252. fromJSON(data: any, meta?: any): this | null {
  253. this._cameraViews.forEach(v=>this.deleteView(v)) // deserialize pushes to the existing array
  254. if (super.fromJSON(data, meta)) {
  255. this.uiConfig.uiRefresh?.()
  256. return this
  257. }
  258. return null
  259. }
  260. public async animateToObject(selected?: Object3D, distanceMultiplier = 4, duration?: number, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 5.0}) {
  261. if (!this._viewer) return
  262. const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot.modelObject, false, true)
  263. const center = bbox.getCenter(new Vector3())
  264. const size = bbox.getSize(new Vector3())
  265. const radius = size.length() / 2
  266. await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, radius * distanceMultiplier)), center, duration, ease)
  267. }
  268. public async animateToFitObject(selected?: Object3D, distanceMultiplier = 1.5, duration = 1000, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 50.0}) {
  269. if (!this._viewer) return
  270. const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot, false, true)
  271. const center = bbox.getCenter(new Vector3()) // world position
  272. const size = bbox.getSize(new Vector3())
  273. const cam = this._viewer.scene.mainCamera
  274. let cameraZ = 1
  275. if (cam.isPerspectiveCamera) {
  276. // get the max side of the bounding box (fits to width OR height as needed )
  277. const fov = (cam as PerspectiveCamera2).fov * (Math.PI / 180)
  278. const fovh = 2 * Math.atan(Math.tan(fov / 2) * cam.aspect)
  279. const dx = size.z / 2 + Math.abs(size.x / 2 / Math.tan(fovh / 2))
  280. const dy = size.z / 2 + Math.abs(size.y / 2 / Math.tan(fov / 2))
  281. cameraZ = Math.max(dx, dy)
  282. }
  283. await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, cameraZ * distanceMultiplier)), center, duration, ease)
  284. }
  285. /**
  286. *
  287. * @param distanceFromTarget - in world units
  288. * @param center - target (center) of the view in world coordinates
  289. * @param duration - in milliseconds
  290. * @param ease
  291. */
  292. public async animateToTarget(distanceFromTarget: number, center: Vector3, duration?: number, ease?: Easing|EasingFunctionType) {
  293. const view = this.getView() // world space
  294. view.target.copy(center)
  295. const direction = new Vector3().subVectors(view.target, view.position).normalize()
  296. view.position.copy(direction.multiplyScalar(-distanceFromTarget).add(view.target))
  297. await this.animateToView(view, duration, ease)
  298. }
  299. uiConfig: UiObjectConfig = {
  300. type: 'folder',
  301. label: 'Camera Views',
  302. // expanded: true,
  303. children: [
  304. ()=>[...this._cameraViews.map(view => view.uiConfig)],
  305. ...generateUiConfig(this) || [],
  306. ],
  307. }
  308. get animationLooping(): boolean {
  309. return this._animationLooping
  310. }
  311. private _viewQueue: CameraView[] = []
  312. private _animationLooping = false
  313. private _infiniteLooping = true
  314. private async _animationLoop() {
  315. if (this._animationLooping) return
  316. this._animationLooping = true
  317. while (this.viewLooping || !this._infiniteLooping) {
  318. if (this.isDisabled()) break
  319. if (this._cameraViews.length < 1) break
  320. if (this._viewQueue.length === 0) {
  321. if (this._infiniteLooping) this._viewQueue.push(...this._cameraViews)
  322. else break
  323. }
  324. await this.animateToView(this._viewQueue.shift()!)
  325. await timeout(2 + this.viewPauseTime) // ms delay
  326. }
  327. this._animationLooping = false
  328. }
  329. // region deprecated
  330. /**
  331. * @deprecated - renamed to {@link getView} or {@link ICamera.getView}
  332. * @param camera
  333. * @param worldSpace
  334. */
  335. getCurrentCameraView(camera?: ICamera, worldSpace = true) {
  336. return this.getView(camera, worldSpace)
  337. }
  338. /**
  339. * @deprecated - renamed to {@link setView} or {@link ICamera.setView}
  340. * @param view
  341. */
  342. setCurrentCameraView(view: CameraView) {
  343. return this.setView(view)
  344. }
  345. /**
  346. * @deprecated - use {@link animateToView} instead
  347. * @param view
  348. */
  349. async focusView(view: CameraView) {
  350. return this.animateToView(view)
  351. }
  352. // endregion
  353. // region to be ported to other plugins
  354. // /**
  355. // * For slight rotation of camera when seekOnScroll is enabled
  356. // */
  357. // private _pointerMove(ev: PointerEvent) {
  358. // if (this.isDisabled()) return
  359. // if (!this._animating && this.seekOnScroll) {
  360. // const cam = this._viewer?.scene.mainCamera
  361. // if (!cam) return
  362. // const s = new Spherical()
  363. // const p = cam.position
  364. // const t = cam.target
  365. // const q = new Quaternion().setFromUnitVectors(cam.cameraObject.up, new Vector3(0, 1, 0))
  366. // const qi = q.clone().invert()
  367. // const offset = p.clone().sub(t)
  368. // offset.applyQuaternion(q)
  369. // s.setFromVector3(offset)
  370. // s.theta += this.rotationOffset * ev.movementX / this._viewer!.canvas!.clientWidth
  371. // s.phi += this.rotationOffset * ev.movementY / this._viewer!.canvas!.clientHeight
  372. // s.makeSafe()
  373. // offset.setFromSpherical(s)
  374. // offset.applyQuaternion(qi)
  375. // p.copy(t).add(offset)
  376. // cam.setDirty()
  377. // }
  378. // }
  379. // // @uiToggle() @serialize()
  380. // animateOnScroll = false // buggy
  381. //
  382. // @uiToggle() @serialize()
  383. // seekOnScroll = false
  384. // private _scrollAnimationState = 0
  385. // scrollAnimationDamping = 0.1
  386. // private _wheel(ev: any | WheelEvent) {
  387. // if (this.isDisabled()) return
  388. // if (this.seekOnScroll && !this._animating) {
  389. // // if (ev.deltaY > 0) this.focusNext(false)
  390. // // else this.focusPrevious(false)
  391. // } else if (Math.abs(ev.deltaY) > 0.001) {
  392. // this._scrollAnimationState = -1. * Math.sign(ev.deltaY)
  393. // }
  394. // }
  395. // private _driver: Driver = (update)=>{
  396. // return {
  397. // start: ()=>this._updaters.push({u:update, time:0}),
  398. // stop: ()=> this._updaters.splice(this._updaters.findIndex(u=>u.u === update), 1),
  399. // }
  400. // }
  401. // private _fadeDisabled = false
  402. // todo: same code used in PopmotionPlugin, merge somehow
  403. // private _postFrame() {
  404. // if (!this._viewer) return
  405. // if (this.isDisabled() || !this._animating) {
  406. // this._lastFrameTime = 0
  407. // if (this._fadeDisabled) {
  408. // this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')?.enable(CameraViewPlugin.PluginType)
  409. // this._fadeDisabled = false
  410. // }
  411. // // console.log('not anim')
  412. // return
  413. // }
  414. // const time = now() / 1000.0
  415. // if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 60.0
  416. // let delta = time - this._lastFrameTime
  417. // this._lastFrameTime = time
  418. // delta = delta * (this.animateOnScroll ? this._scrollAnimationState : 1)
  419. //
  420. // const d = this._viewer.getPluginByType<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta()
  421. // if (d && d > 0) delta = d
  422. // if (d === 0) return // not converged yet.
  423. // // if d < 0: not recording, do nothing
  424. //
  425. // delta *= 1000
  426. //
  427. // // delta = 16.666
  428. //
  429. // // console.log(delta)
  430. // // console.log(dt)
  431. // //
  432. //
  433. // if (delta <= 0) return
  434. //
  435. // this._updaters.forEach(u=>{
  436. // let dt = delta
  437. // if (u.time + dt < 0) dt = -u.time
  438. // u.time += dt
  439. // if (Math.abs(dt) > 0.001)
  440. // u.u(dt)
  441. // })
  442. // if (this._scrollAnimationState < 0.001) this._scrollAnimationState = 0
  443. // else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping
  444. //
  445. // if (!this._fadeDisabled) {
  446. // const ff = this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')
  447. // if (ff) {
  448. // ff.disable(CameraViewPlugin.PluginType)
  449. // this._fadeDisabled = true
  450. // }
  451. // }
  452. // }
  453. // @uiButton('Record All Views')
  454. // public async recordAllViews(onStart?: ()=>void, downloadOnEnd = true) {
  455. // if (this.isDisabled()) return
  456. // const recorder = this._viewer?.getPluginByType<CanvasRecorderPlugin>('CanvasRecorder')
  457. // if (!recorder || !recorder.enabled) return
  458. // if (this._cameraViews.length < 1) return
  459. // await this.resetToFirstView()
  460. // if (recorder.isRecording()) {
  461. // console.error('CanvasRecorderPlugin is already recording')
  462. // return
  463. // }
  464. // return new Promise<Blob|undefined>((resolve, reject) => {
  465. // const listener2 = ()=>{
  466. // recorder.removeEventListener('start', listenerStart)
  467. // recorder.removeEventListener('stop', listener2)
  468. // recorder.removeEventListener('error', listenerError)
  469. // }
  470. // const listenerStart = async() => {
  471. // listener2()
  472. // onStart?.()
  473. // await this.animateAllViews()
  474. // const blob = await recorder.stopRecording()
  475. // if (downloadOnEnd) {
  476. // const name = await this._viewer?.prompt('Canvas Recorder: Save file as', 'recording.mp4')
  477. // if (name !== null && blob) await this._downloadBlob(blob, name || 'recording.mp4')
  478. // }
  479. // resolve(blob)
  480. // }
  481. // const listenerError = async() => {
  482. // listener2()
  483. // reject()
  484. // }
  485. // recorder.addEventListener('start', listenerStart)
  486. // recorder.addEventListener('stop', listener2)
  487. // recorder.addEventListener('error', listenerError)
  488. // if (!recorder.startRecording()) {
  489. // console.error('cannot start recording')
  490. // return
  491. // }
  492. // })
  493. // }
  494. // private async _downloadBlob(blob: Blob, name: string) {
  495. // const tr = this._viewer?.getPluginByType<FileTransferPlugin>('FileTransferPlugin')
  496. // if (!tr) {
  497. // this._viewer?.console.error('FileTransferPlugin required to export/download file')
  498. // return
  499. // }
  500. // await tr.exportFile(blob, name)
  501. // }
  502. // endregion
  503. }