UNPKG

threepipe

Version:

A 3D viewer framework built on top of three.js in TypeScript with a focus on quality rendering, modularity and extensibility.

431 lines 17.4 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { Vector3 } from 'three'; import { AViewerPluginSync } from '../../viewer'; import { Box3B } from '../../three'; import { onChange, serialize, timeout } from 'ts-browser-helpers'; import { generateUiConfig, uiButton, uiDropdown, uiInput, uiSlider, uiToggle } from 'uiconfig.js'; import { EasingFunctions } from '../../utils'; import { CameraView } from '../../core'; import { PopmotionPlugin } from './PopmotionPlugin'; import { InteractionPromptPlugin } from '../interaction/InteractionPromptPlugin'; import { getFittingDistance } from '../../three/utils/camera'; /** * 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 { // get dirty() { // todo: issue with recorder convergeMode? // return this._animating // } constructor(options = {}) { super(); this.enabled = true; this._cameraViews = []; this.viewLooping = false; /** * Pauses time between view changes when animating all views or looping. */ this.viewPauseTime = 200; /** * {@link EasingFunctions} */ this.animEase = 'easeInOutSine'; // ms this.animDuration = 1000; // ms this.interpolateMode = 'spherical'; // not used this.rotationOffset = 0.25; this._animating = false; this.dependencies = [PopmotionPlugin]; this._viewSetView = ({ view, camera }) => { if (!view) { this._viewer?.console.warn('Invalid view', view); return; } this.setView(view, camera); }; this._viewUpdateView = ({ view, camera }) => { if (!view) { this._viewer?.console.warn('Invalid view', view); return; } const name = view.name; this.getView(camera, view.isWorldSpace ?? true, view); view.name = name; }; this._viewDeleteView = ({ view }) => { if (!view) { this._viewer?.console.warn('Invalid view', view); return; } this.deleteView(view); }; this._viewAnimateView = async ({ view, camera, duration, easing, throwOnStop }) => { if (!view) { this._viewer?.console.warn('Invalid view', view); return; } return this.animateToView(view, duration || this.animDuration, easing || this.animEase, camera, throwOnStop); }; this.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); }; this.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); }; this._popAnimations = []; this.uiConfig = { type: 'folder', label: 'Camera Views', // expanded: true, children: [ () => [...this._cameraViews.map(view => view.uiConfig)], ...generateUiConfig(this) || [], ], }; this._viewQueue = []; this._animationLooping = false; this._infiniteLooping = true; 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; } get cameraViews() { return this._cameraViews; } get camViews() { return this._cameraViews; } get animating() { return this._animating; } // private _updaters: {u: ((timestamp: number) => void), time: number}[] = [] // private _lastFrameTime = 0 // for post frame onAdded(viewer) { super.onAdded(viewer); // todo: move to PopmotionPlugin // todo: remove event listener viewer.addEventListener('preFrame', (_) => { // 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) { // viewer.removeEventListener('postFrame', this._postFrame) // window.removeEventListener('wheel', this._wheel) // window.removeEventListener('pointermove', this._pointerMove) return super.onRemove(viewer); } async resetToFirstView(duration = 100) { if (this.isDisabled()) return; this._currentView = undefined; await this.animateToView(0, duration); await timeout(2); } 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) { if (!this._cameraViews.includes(view)) this._cameraViews.push(view); view.addEventListener('setView', this._viewSetView); view.addEventListener('updateView', this._viewUpdateView); view.addEventListener('deleteView', this._viewDeleteView); view.addEventListener('animateView', this._viewAnimateView); this.uiConfig.uiRefresh?.(); this.dispatchEvent({ type: 'viewAdd', view }); } deleteView(view) { const i = this._cameraViews.indexOf(view); if (i >= 0) this._cameraViews.splice(i, 1); view.removeEventListener('setView', this._viewSetView); view.removeEventListener('updateView', this._viewUpdateView); view.removeEventListener('deleteView', this._viewDeleteView); view.removeEventListener('animateView', this._viewAnimateView); this.uiConfig.uiRefresh?.(); this.dispatchEvent({ type: 'viewDelete', view }); } getView(camera, worldSpace = true, view) { camera = camera || this._viewer?.scene.mainCamera; if (!camera) return view ?? new CameraView(); return camera.getView(worldSpace, view); } setView(view, camera) { camera = camera || this._viewer?.scene.mainCamera; if (!camera) return; camera.setView(view); } async animateToView(_view, duration, easing, camera, 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 = (typeof easing === 'function' ? easing : EasingFunctions[easing || this.animEase]); // 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); } 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; } async stopAllAnimations() { this.viewLooping = false; this._popAnimations.forEach(a => a?.stop?.()); this._popAnimations = []; while (this._animating || this._animationLooping) { await timeout(100); } } fromJSON(data, meta) { 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; } async animateToObject(selected, distanceMultiplier = 4, duration, ease, 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); } async animateToFitObject(selected, distanceMultiplier = 1.5, duration = 1000, ease, 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 */ async animateToTarget(distanceFromTarget, center, duration, ease) { 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); } get animationLooping() { return this._animationLooping; } 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, worldSpace = true) { return this.getView(camera, worldSpace); } /** * @deprecated - renamed to {@link setView} or {@link ICamera.setView} * @param view */ setCurrentCameraView(view) { return this.setView(view); } /** * @deprecated - use {@link animateToView} instead * @param view */ async focusView(view) { return this.animateToView(view); } } CameraViewPlugin.PluginType = 'CameraViews'; __decorate([ serialize('cameraViews') ], CameraViewPlugin.prototype, "_cameraViews", void 0); __decorate([ onChange(CameraViewPlugin.prototype._animationLoop) /** * Loop all views indefinitely. */ , serialize(), uiToggle('Loop All Views') ], CameraViewPlugin.prototype, "viewLooping", void 0); __decorate([ serialize(), uiInput('View Pause Time') ], CameraViewPlugin.prototype, "viewPauseTime", void 0); __decorate([ serialize(), uiDropdown('Ease', Object.keys(EasingFunctions).map((label) => ({ label }))) ], CameraViewPlugin.prototype, "animEase", void 0); __decorate([ serialize(), uiSlider('Duration', [10, 10000], 10) ], CameraViewPlugin.prototype, "animDuration", void 0); __decorate([ serialize(), uiDropdown('Interpolation', ['spherical', 'linear'].map((label) => ({ label }))) ], CameraViewPlugin.prototype, "interpolateMode", void 0); __decorate([ serialize() // @uiSlider('RotationOffset', [0.2, 0.75], 0.01) ], CameraViewPlugin.prototype, "rotationOffset", void 0); __decorate([ uiButton('Reset To First View', { sendArgs: false }) ], CameraViewPlugin.prototype, "resetToFirstView", null); __decorate([ uiButton('Add Current View') ], CameraViewPlugin.prototype, "addCurrentView", null); __decorate([ uiButton('Focus Next', { sendArgs: false }) ], CameraViewPlugin.prototype, "focusNext", void 0); __decorate([ uiButton('Focus Previous', { sendArgs: false }) ], CameraViewPlugin.prototype, "focusPrevious", void 0); __decorate([ uiButton('Animate All Views') ], CameraViewPlugin.prototype, "animateAllViews", null); __decorate([ uiButton('Stop All Animations') ], CameraViewPlugin.prototype, "stopAllAnimations", null); //# sourceMappingURL=CameraViewPlugin.js.map