threepipe
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

CameraViewPlugin.ts 22KB

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