threepipe
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

CameraViewPlugin.ts 22KB

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