UNPKG

threepipe

Version:

A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.

577 lines 23.9 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, onChange3, serialize, timeout } from 'ts-browser-helpers'; import { generateUiConfig, uiButton, uiDropdown, uiInput, uiSlider, uiToggle } from 'uiconfig.js'; import { EasingFunctions } from '../../utils'; import { CameraView, createCameraPath } 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'; // todo spline // @serialize() @uiDropdown('Spline Curve', ['centripetal', 'chordal', 'catmullrom'].map((label:string)=>({label})), (t: CameraViewPlugin)=>({hidden: ()=>t.interpolateMode !== 'spline', onChange: ()=>t.uiConfig?.uiRefresh?.()})) // splineCurve: 'centripetal'|'chordal'|'catmullrom' = 'chordal' // not used this.rotationOffset = 0.25; this._animating = false; this.dependencies = [PopmotionPlugin]; 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._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._viewUpdated = async (e) => { if (!this._cameraViews.includes(e.target)) return; this.dispatchEvent({ type: 'viewUpdate', view: e.target }); this.setDirty({ key: 'cameraViews', change: 'viewUpdate' }); }; // endregion this._lastAnimTime = -1; 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.setDirty = this.setDirty.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, force = false) { view.addEventListener('setView', this._viewSetView); view.addEventListener('updateView', this._viewUpdateView); view.addEventListener('deleteView', this._viewDeleteView); view.addEventListener('animateView', this._viewAnimateView); view.addEventListener('update', this._viewUpdated); const incl = this._cameraViews.includes(view); if (!incl || force) { if (!incl) this._cameraViews.push(view); this.setDirty({ key: 'cameraViews', change: 'viewAdd' }); this.dispatchEvent({ type: 'viewAdd', view }); } } deleteView(view, force = false) { const i = this._cameraViews.indexOf(view); view.removeEventListener('setView', this._viewSetView); view.removeEventListener('updateView', this._viewUpdateView); view.removeEventListener('deleteView', this._viewDeleteView); view.removeEventListener('animateView', this._viewAnimateView); view.removeEventListener('update', this._viewUpdated); if (i >= 0 || force) { if (i >= 0) this._cameraViews.splice(i, 1); this.setDirty({ key: 'cameraViews', change: 'viewDelete' }); 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 = []; // const viewIndex = this.camViews.indexOf(view) // let interpolateMode = this.interpolateMode // if (viewIndex < 0) { // if (interpolateMode === 'spline') { // console.warn('CameraViewPlugin - Cannot animate along a spline with external camera view, fallback to spherical') // interpolateMode = 'spherical' // } // } // // if (interpolateMode === 'spline') { // const points = this.camViews.map(c=>c.position.clone()) // const spline = new CatmullRomCurve3(points, true, this.splineCurve) // // const getPosition = (t: number)=>{ // const v = new Vector3() // const ip = 1. / points.length // const i = viewIndex === 0 ? points.length : viewIndex // const d = (i - 1) * ip // spline.getPointAt(d + t * ip, v) // return v // } // // pms.push(animateAsync({ // // from: camera.position.clone(), // // to: view.position.clone(), // from: 0, // to: 1, // duration, ease, driver, // onUpdate: (v) => camera.position = getPosition(v), // onComplete: () => camera.position = getPosition(1), // camera.position = view.position, // onStop: ()=> { // throw new Error('Animation stopped') // }, // }, popAnimations)) // // if (new Vector3().subVectors(camera.cameraObject.up, view.up).length() > 0.1) // // pms.push(animateAsync({ // // from: camera.cameraObject.up.clone(), // // to: view.up.clone(), // // duration, ease, driver, // // onUpdate: (v) => camera.cameraObject.up.copy(v), // // onComplete: () => camera.cameraObject.up.copy(view.up), // // })) // // if (new Vector3().subVectors(camera.target, view.target).length() > 0.1) // pms.push(animateAsync({ // from: camera.target.clone(), // to: view.target.clone(), // duration, ease, driver, // onUpdate: (v) => { // camera.target = v // camera.targetUpdated() // }, // onComplete: () => { // camera.target = view.target // camera.targetUpdated() // }, // }, 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, true)); this.uiConfig?.uiRefresh?.(); return this; } return null; } setDirty(ops) { this.uiConfig?.uiRefresh?.(false, 'postFrame'); this.dispatchEvent({ ...ops, type: 'update' }); } 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 selectedArray = (Array.isArray(selected) ? selected : selected ? [selected] : []) .filter(Boolean) .flatMap(m => { if (!m.isMaterial) return m; return m.appliedMeshes; }); const bbox = new Box3B().expandByObject(!selectedArray.length ? this._viewer.scene.modelRoot : selectedArray[0], false, true); for (let i = 1; i < selectedArray.length; i++) { bbox.expandByObject(selectedArray[i], 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); } _postFrame() { if (!this.enabled || !this._viewer) return; const camera = this._viewer.scene.mainCamera; if (!camera) return; if (!this._viewer.timeline.shouldRun() || !this._cameraViews.length) { camera.setInteractions(true, CameraViewPlugin.PluginType + '-postFrame'); this._lastAnimTime = -1; return; } camera.setInteractions(false, CameraViewPlugin.PluginType + '-postFrame'); const time = this._viewer.timeline.time; // const delta = this._viewer.timeline.delta || 0 if (time == this._lastAnimTime) return; this._lastAnimTime = time; const timeline = []; const viewDuration = this.animDuration || 1000; const pauseTime = this.viewPauseTime || 0; const views = this._cameraViews; let time1 = 0; for (let i = 0; i < views.length; i++) { const view = views[i]; const duration = Math.max(2, view.duration * viewDuration) / 1000; timeline.push({ time: time1, index: i, duration: duration, }); time1 += duration + pauseTime / 1000; } const selectedTime = timeline .sort((a, b) => -a.time + b.time) .find(t => t.time <= time); if (!selectedTime) return; // todo? const viewIndex = selectedTime.index; const start = selectedTime.time; const duration = selectedTime.duration ?? 0.5; const t = duration < 1e-6 ? 1 : (time - start) / duration; // const dt = duration < 1e-6 ? 0 : delta / duration if (t > 1) return; // todo? // todo cache path const { getPosition, getTarget } = createCameraPath(this.camViews); getPosition(t, viewIndex, camera.position); getTarget(t, viewIndex, camera.target); camera.setDirty(); return true; } } 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([ onChange3('setDirty'), serialize(), uiInput('View Pause Time') ], CameraViewPlugin.prototype, "viewPauseTime", void 0); __decorate([ onChange3('setDirty'), serialize(), uiDropdown('Ease', Object.keys(EasingFunctions).map((label) => ({ label }))) ], CameraViewPlugin.prototype, "animEase", void 0); __decorate([ onChange3('setDirty'), serialize(), uiSlider('Duration', [10, 10000], 10) ], CameraViewPlugin.prototype, "animDuration", void 0); __decorate([ onChange3('setDirty'), serialize(), uiDropdown('Interpolation', ['spherical', 'linear' /* , 'spline (dev)'*/].map((label) => ({ label, value: label.split(' ')[0] }))) ], CameraViewPlugin.prototype, "interpolateMode", void 0); __decorate([ serialize() // @onChange3('setDirty') // @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