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
JavaScript
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