| @@ -94,6 +94,7 @@ To make changes and run the example, click on the CodePen button on the top righ | |||
| - [PickingPlugin](#pickingplugin) - Adds support for selecting objects in the viewer with user interactions and selection widgets | |||
| - [GLTFAnimationPlugin](#gltfanimationplugin) - Add support for playing and seeking gltf animations | |||
| - [PopmotionPlugin](#popmotionplugin) - Integrates with popmotion.io library for animation/tweening | |||
| - [CameraViewPlugin](#cameraviewplugin) - Add support for saving, loading, animating, looping between camera views | |||
| - [RenderTargetPreviewPlugin](#rendertargetpreviewplugin) - Preview any render target in a UI panel over the canvas | |||
| - [GeometryUVPreviewPlugin](#geometryuvpreviewplugin) - Preview UVs of any geometry in a UI panel over the canvas | |||
| - [FrameFadePlugin](#framefadeplugin) - Post-render pass to smoothly fade to a new rendered frame over time | |||
| @@ -1749,6 +1750,8 @@ camera.deactivateMain() | |||
| [`camera.deactivateMain`](https://threepipe.org/docs/classes/PerspectiveCamera2.html#deactivateMain) - Deactivate the camera as the main camera. | |||
| See also [CameraViewPlugin](#cameraviewplugin) for camera focus animation. | |||
| ## AssetManager | |||
| Source Code: [src/assetmanager/AssetManager.ts](./src/assetmanager/AssetManager.ts) | |||
| @@ -2234,6 +2237,73 @@ await popmotion.animateAsync({ // Also await for the animation. | |||
| Note: The animation is started when the animate or animateAsync function is called. | |||
| ## CameraViewPlugin | |||
| [//]: # (todo: image) | |||
| Example: https://threepipe.org/examples/#camera-view-plugin/ | |||
| Source Code: [src/plugins/animation/CameraViewPlugin.ts](./src/plugins/ui/RenderTargetPreviewPlugin.ts) | |||
| API Reference: [CameraViewPlugin](https://threepipe.org/docs/classes/CameraViewPlugin.html) | |||
| CameraViewPlugin adds support to save and load camera views, which can then be animated to. | |||
| It uses PopmotionPlugin internally to animate any camera to a saved view or to loop through all the saved views. | |||
| It also provides a UI to manage the views. | |||
| ```typescript | |||
| import {CameraViewPlugin, ThreeViewer, CameraView, Vector3, Quaternion, EasingFunctions, timeout} from 'threepipe' | |||
| const viewer = new ThreeViewer({...}) | |||
| const cameraViewPlugin = viewer.addPluginSync(new CameraViewPlugin()) | |||
| const intialView = cameraViewPlugin.getView() | |||
| // or = viewer.scene.mainCamera.getView() | |||
| // create a new view | |||
| const view = new CameraView( | |||
| 'My View', // name | |||
| new Vector3(0, 0, 10), // position | |||
| new Vector3(0, 0, 0), // target | |||
| new Quaternion(0, 0, 0, 1), // quaternion rotation | |||
| 1 // zoom | |||
| ) | |||
| // or clone a view | |||
| const view2 = intialView.clone() | |||
| view2.position.add(new Vector3(0, 5, 0)) // move up 5 units | |||
| // animate the main camera to a view | |||
| await cameraViewPlugin.animateToView( | |||
| view, | |||
| 2000, // in ms, = 2sec | |||
| EasingFunctions.easeInOut, | |||
| ).catch(()=>console.log('Animation stopped')) | |||
| // stop any/all animations | |||
| cameraViewPlugin.stopAllAnimations() | |||
| // add views to the plugin | |||
| cameraViewPlugin.addView(view) | |||
| cameraViewPlugin.addView(view2) | |||
| cameraViewPlugin.addView(intialView) | |||
| cameraViewPlugin.addCurrentView() // adds the current view of the main camera | |||
| // loop through all the views once | |||
| cameraViewPlugin.animDuration = 2000 // default duration | |||
| cameraViewPlugin.animEase = EasingFunctions.easeInOutSine // default easing | |||
| await cameraViewPlugin.animateAllViews() | |||
| // loop through all the views forever | |||
| cameraViewPlugin.viewLooping = true | |||
| await timeout(10000) // wait for some time | |||
| // stop looping | |||
| cameraViewPlugin.viewLooping = false | |||
| ``` | |||
| ## RenderTargetPreviewPlugin | |||
| @@ -0,0 +1,36 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>Camera View Plugin</title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <!-- Import maps polyfill --> | |||
| <!-- Remove this when import maps will be widely supported --> | |||
| <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> | |||
| <script type="importmap"> | |||
| { | |||
| "imports": { | |||
| "threepipe": "./../../dist/index.mjs", | |||
| "@threepipe/plugin-tweakpane": "./../../plugins/tweakpane/dist/index.mjs" | |||
| } | |||
| } | |||
| </script> | |||
| <style id="example-style"> | |||
| html, body, #canvas-container, #mcanvas { | |||
| width: 100%; | |||
| height: 100%; | |||
| margin: 0; | |||
| overflow: hidden; | |||
| } | |||
| </style> | |||
| <script type="module" src="../examples-utils/simple-code-preview.mjs"></script> | |||
| <script id="example-script" type="module" src="./script.js" data-scripts="./script.ts;./script.js"></script> | |||
| </head> | |||
| <body> | |||
| <div id="canvas-container"> | |||
| <canvas id="mcanvas"></canvas> | |||
| </div> | |||
| </body> | |||
| @@ -0,0 +1,79 @@ | |||
| import {_testFinish, CameraView, CameraViewPlugin, EasingFunctions, ThreeViewer, Vector3} from 'threepipe' | |||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||
| import {createSimpleButtons} from '../examples-utils/simple-bottom-buttons.js' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| }) | |||
| const cameraViewPlugin = viewer.addPluginSync(CameraViewPlugin) | |||
| console.log(cameraViewPlugin) | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| await viewer.load('https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf', { | |||
| autoCenter: true, | |||
| autoScale: true, | |||
| }) | |||
| // Get the current camera view and save it in a variable | |||
| const initialView = cameraViewPlugin.getView() | |||
| const topView = new CameraView( | |||
| 'topView', | |||
| new Vector3(0, 6, 0), | |||
| initialView.target, | |||
| ) | |||
| const leftView = new CameraView( | |||
| 'leftView', | |||
| new Vector3(-6, 0, 0), | |||
| initialView.target, | |||
| ) | |||
| const rightView = new CameraView( | |||
| 'leftView', | |||
| new Vector3(6, 0, 0), | |||
| initialView.target, | |||
| ) | |||
| createSimpleButtons({ | |||
| ['Top View']: async() => cameraViewPlugin.animateToView(topView, 1000, EasingFunctions.easeInOutSine), | |||
| ['Left View']: async() => cameraViewPlugin.animateToView(leftView, 1000, EasingFunctions.easeInOutSine), | |||
| ['Right View']: async() => cameraViewPlugin.animateToView(rightView, 1000, EasingFunctions.easeInOutSine), | |||
| ['Pan right/left']: async(btn) => { | |||
| btn.disabled = true | |||
| const currentView = cameraViewPlugin.getView() | |||
| await cameraViewPlugin.animateToView(new CameraView( | |||
| 'view', | |||
| currentView.position, | |||
| new Vector3(4, 0, 0).sub(currentView.target), | |||
| )) | |||
| btn.disabled = false | |||
| }, | |||
| ['Move up/down']: async(btn) => { | |||
| btn.disabled = true | |||
| const currentView = cameraViewPlugin.getView() | |||
| await cameraViewPlugin.animateToView(new CameraView( | |||
| 'view', | |||
| new Vector3(currentView.position.x, 5 - currentView.position.y, currentView.position.z), | |||
| currentView.target, | |||
| )) | |||
| btn.disabled = false | |||
| }, | |||
| ['Reset']: async() => cameraViewPlugin.animateToView(initialView, 1000, EasingFunctions.easeInOutSine), | |||
| }) | |||
| const ui = viewer.addPluginSync(TweakpaneUiPlugin, true) | |||
| ui.appendChild(viewer.scene.mainCamera.uiConfig) | |||
| const uiC = ui.setupPluginUi(CameraViewPlugin)! | |||
| uiC.expanded = true | |||
| uiC.uiRefresh?.() | |||
| } | |||
| init().then(_testFinish) | |||
| @@ -1,5 +1,6 @@ | |||
| import { | |||
| _testFinish, | |||
| CameraViewPlugin, | |||
| DepthBufferPlugin, | |||
| DropzonePlugin, | |||
| FrameFadePlugin, | |||
| @@ -45,6 +46,7 @@ async function init() { | |||
| await viewer.addPlugins([ | |||
| new ProgressivePlugin(), | |||
| new GLTFAnimationPlugin(), | |||
| new CameraViewPlugin(), | |||
| new ViewerUiConfigPlugin(), | |||
| // new SceneUiConfigPlugin(), // this is already in ViewerUiPlugin | |||
| new DepthBufferPlugin(HalfFloatType, true, true), | |||
| @@ -68,10 +70,11 @@ async function init() { | |||
| ['Viewer']: [ViewerUiConfigPlugin, SceneUiConfigPlugin, DropzonePlugin, FullScreenPlugin], | |||
| ['GBuffer']: [DepthBufferPlugin, NormalBufferPlugin], | |||
| ['Post-processing']: [TonemapPlugin, ProgressivePlugin, FrameFadePlugin], | |||
| ['Animation']: [GLTFAnimationPlugin], | |||
| ['Animation']: [GLTFAnimationPlugin, CameraViewPlugin], | |||
| ['Debug']: [RenderTargetPreviewPlugin], | |||
| }) | |||
| viewer.scene.addObject(new HemisphereLight(0xffffff, 0x444444, 5)) | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| // const result = await viewer.load<IObject3D>('https://cdn.jsdelivr.net/gh/KhronosGroup/glTF-Blender-Exporter@master/polly/project_polly.gltf', { | |||
| @@ -2,6 +2,7 @@ import {Camera, Vector3} from 'three' | |||
| import {IObject3D, IObject3DEvent, IObject3DEventTypes, IObject3DUserData, IObjectSetDirtyOptions} from './IObject' | |||
| import {IShaderPropertiesUpdater} from '../materials' | |||
| import {ICameraControls, TControlsCtor} from './camera/ICameraControls' | |||
| import {CameraView, ICameraView} from './camera/CameraView' | |||
| /** | |||
| * Available modes for {@link ICamera.controlsMode} property. | |||
| @@ -27,10 +28,10 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame | |||
| readonly isCamera: true | |||
| setDirty(options?: ICameraSetDirtyOptions): void; | |||
| near: number; | |||
| far: number; | |||
| readonly isMainCamera: boolean; | |||
| readonly isPerspectiveCamera?: boolean; | |||
| readonly isOrthographicCamera?: boolean; | |||
| activateMain(options?: Partial<ICameraEvent>, _internal?: boolean, _refresh?: boolean): void; | |||
| deactivateMain(options?: Partial<ICameraEvent>, _internal?: boolean, _refresh?: boolean): void; | |||
| @@ -53,9 +54,15 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame | |||
| */ | |||
| position: Vector3, | |||
| // todo: make disable/enable functions with key like in FrameFadePlugin | |||
| interactionsEnabled: boolean; | |||
| readonly canUserInteract: boolean; | |||
| /** | |||
| * Check whether user can interact with this camera. | |||
| * Interactions can be enabled/disabled in a variety of ways, | |||
| * like {@link interactionsEnabled}, {@link controlsMode}, {@link isMainCamera} property | |||
| */ | |||
| readonly canUserInteract: boolean; | |||
| zoom: number; | |||
| /** | |||
| @@ -72,6 +79,16 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame | |||
| controlsMode?: TCameraControlsMode; // todo add more. | |||
| // controlsEnabled: boolean; // use controlsMode = '' instead | |||
| /** | |||
| * Automatically managed when {@link autoNearFar} is `true`. See also {@link minNearPlane} | |||
| */ | |||
| near: number; | |||
| /** | |||
| * Automatically managed when {@link autoNearFar} is `true`. See also {@link maxFarPlane} | |||
| */ | |||
| far: number; | |||
| // also in userData | |||
| autoNearFar: boolean // default = true | |||
| minNearPlane: number // default = 0.2 | |||
| @@ -85,7 +102,6 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame | |||
| */ | |||
| isActiveCamera: boolean; | |||
| setControlsCtor(key: string, ctor: TControlsCtor, replace?: boolean): void; | |||
| removeControlsCtor(key: string): void; | |||
| refreshCameraControls(setDirty?: boolean): void | |||
| @@ -93,6 +109,22 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame | |||
| updateProjectionMatrix(): void | |||
| fov?: number | |||
| getView<T extends ICameraView = CameraView>(worldSpace?: boolean, cameraView?: T): T | |||
| setView(view: ICameraView): void | |||
| /** | |||
| * Set camera view from another camera. | |||
| * @param camera | |||
| * @param distanceFromTarget - default = 4 | |||
| * @param worldSpace - default = true | |||
| */ | |||
| setViewFromCamera(camera: ICamera|Camera, distanceFromTarget?: number, worldSpace?: boolean): void | |||
| /** | |||
| * Dispatches the `setView` event which triggers the main camera to set its view to this camera's view. | |||
| * @param eventOptions | |||
| */ | |||
| setViewToMain(eventOptions: Partial<ICameraEvent>): void | |||
| // region inherited type fixes | |||
| // re-declaring from IObject3D because: https://github.com/microsoft/TypeScript/issues/16936 | |||
| @@ -102,7 +134,7 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame | |||
| getObjectById<T extends IObject3D = IObject3D>(id: number): T | undefined | |||
| getObjectByName<T extends IObject3D = IObject3D>(name: string): T | undefined | |||
| getObjectByProperty<T extends IObject3D = IObject3D>(name: string, value: string): T | undefined | |||
| copy(source: this, recursive?: boolean, distanceFromTarget?: number, ...args: any[]): this | |||
| copy(source: this, recursive?: boolean, distanceFromTarget?: number, worldSpace?: boolean, ...args: any[]): this | |||
| clone(recursive?: boolean): this | |||
| add(...object: IObject3D[]): this | |||
| remove(...object: IObject3D[]): this | |||
| @@ -0,0 +1,52 @@ | |||
| import {Event, EventDispatcher, Quaternion, Vector3} from 'three' | |||
| import {onChange, serializable, serialize} from 'ts-browser-helpers' | |||
| import {IUiConfigContainer, uiButton, uiInput, uiNumber, UiObjectConfig, uiPanelContainer, uiVector} from 'uiconfig.js' | |||
| import {ICamera} from '../ICamera' | |||
| export interface ICameraView{ | |||
| name: string | |||
| position: Vector3 | |||
| target: Vector3 | |||
| quaternion: Quaternion | |||
| zoom: number | |||
| animate(camera?: ICamera, duration?: number): void | |||
| set(camera?: ICamera): void | |||
| } | |||
| @serializable('CameraView') | |||
| @uiPanelContainer('Camera View') | |||
| export class CameraView extends EventDispatcher<Event, 'setView'|'animateView'> implements ICameraView, IUiConfigContainer { | |||
| @onChange(CameraView.prototype._nameChanged) | |||
| @serialize() @uiInput() name = 'Camera View' | |||
| @serialize() @uiVector() position = new Vector3() | |||
| @serialize() @uiVector() target = new Vector3() | |||
| @serialize() @uiVector() quaternion = new Quaternion() | |||
| @serialize() @uiNumber() zoom = 1 | |||
| @uiButton() set = (camera?: ICamera) => this.dispatchEvent({type: 'setView', camera, view: this}) | |||
| @uiButton() animate = (camera?: ICamera, duration?: number) => this.dispatchEvent({type: 'animateView', camera, duration, view: this}) | |||
| constructor(name?: string, position?: Vector3, target?: Vector3, quaternion?: Quaternion, zoom?: number) { | |||
| super() | |||
| if (name !== undefined) this.name = name | |||
| if (position) this.position.copy(position) | |||
| if (target) this.target.copy(target) | |||
| if (quaternion) this.quaternion.copy(quaternion) | |||
| if (zoom !== undefined) this.zoom = zoom | |||
| } | |||
| private _nameChanged() { | |||
| if (this.uiConfig) { | |||
| this.uiConfig.label = this.name | |||
| this.uiConfig.uiRefresh?.() | |||
| } | |||
| } | |||
| clone() { | |||
| return new CameraView(this.name, this.position, this.target, this.quaternion, this.zoom) | |||
| } | |||
| uiConfig?: UiObjectConfig | |||
| // uiConfig = generateUiFolder(this.name, this) | |||
| } | |||
| @@ -10,6 +10,7 @@ import {ThreeSerialization} from '../../utils' | |||
| import {iCameraCommons} from '../object/iCameraCommons' | |||
| import {bindToValue} from '../../three/utils/decorators' | |||
| import {makeICameraCommonUiConfig} from '../object/IObjectUi' | |||
| import {CameraView, ICameraView} from './CameraView' | |||
| // todo: maybe change domElement to some wrapper/base class of viewer | |||
| export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera { | |||
| @@ -45,7 +46,7 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera { | |||
| @serialize() focus: number | |||
| @onChange3(PerspectiveCamera2.prototype.setDirty) | |||
| // @uiSlider('Zoom', [0.001, 20], 0.001) | |||
| @uiSlider('FoV Zoom', [0.001, 10], 0.001) | |||
| @serialize() zoom: number | |||
| @uiVector('Position') | |||
| @@ -162,7 +163,7 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera { | |||
| setDirty(options?: ICameraSetDirtyOptions|Event): void { | |||
| if (!this._positionWorld) return // class not initialized | |||
| if (options?.key === 'fov') this.updateProjectionMatrix() | |||
| if (options?.key === 'fov' || options?.key === 'zoom') this.updateProjectionMatrix() | |||
| this.getWorldPosition(this._positionWorld) | |||
| @@ -316,6 +317,57 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera { | |||
| // endregion | |||
| // region camera views | |||
| getView<T extends ICameraView = CameraView>(worldSpace = true, _view?: T) { | |||
| const up = new Vector3() | |||
| this.updateWorldMatrix(true, false) | |||
| const matrix = this.matrixWorld | |||
| up.x = matrix.elements[4] | |||
| up.y = matrix.elements[5] | |||
| up.z = matrix.elements[6] | |||
| up.normalize() | |||
| const view = _view || new CameraView() | |||
| view.name = this.name | |||
| view.position.copy(this.position) | |||
| view.target.copy(this.target) | |||
| view.quaternion.copy(this.quaternion) | |||
| view.zoom = this.zoom | |||
| // view.up.copy(up) | |||
| const parent = this.parent | |||
| if (parent) { | |||
| if (worldSpace) { | |||
| view.position.applyMatrix4(parent.matrixWorld) | |||
| this.getWorldQuaternion(view.quaternion) | |||
| // target, up is already in world space | |||
| } else { | |||
| up.transformDirection(parent.matrixWorld.clone().invert()) | |||
| // pos is already in local space | |||
| // target should always be in world space | |||
| } | |||
| } | |||
| return view as T | |||
| } | |||
| setView(view: ICameraView) { | |||
| this.position.copy(view.position) | |||
| this.target.copy(view.target) | |||
| // this.up.copy(view.up) | |||
| this.quaternion.copy(view.quaternion) | |||
| this.zoom = view.zoom | |||
| this.setDirty() | |||
| } | |||
| setViewFromCamera(camera: Camera|ICamera, distanceFromTarget?: number, worldSpace = true) { | |||
| // todo: getView, setView can also be used, do we need copy? as that will copy all the properties | |||
| this.copy(camera, undefined, distanceFromTarget, worldSpace) | |||
| } | |||
| setViewToMain(eventOptions: Partial<ICameraEvent>) { | |||
| this.dispatchEvent({type: 'setView', ...eventOptions, camera: this, bubbleToParent: true}) | |||
| } | |||
| // endregion | |||
| // region utils/others | |||
| // for shader prop updater | |||
| @@ -420,14 +472,13 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera { | |||
| } | |||
| /** | |||
| * @deprecated | |||
| * @deprecated - use setDirty directly | |||
| * @param setDirty | |||
| */ | |||
| targetUpdated(setDirty = true): void { | |||
| if (setDirty) this.setDirty() | |||
| } | |||
| // setCameraOptions<T extends Partial<IPerspectiveCameraOptions | IOrthographicCameraOptions>>(value: T, setDirty = true): void { | |||
| // const ops: any = {...value} | |||
| // | |||
| @@ -502,7 +553,7 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera { | |||
| getObjectById: <T extends IObject3D = IObject3D>(id: number) => T | undefined | |||
| getObjectByName: <T extends IObject3D = IObject3D>(name: string) => T | undefined | |||
| getObjectByProperty: <T extends IObject3D = IObject3D>(name: string, value: string) => T | undefined | |||
| copy: (source: ICamera|Camera, recursive?: boolean, distanceFromTarget?: number) => this | |||
| copy: (source: ICamera|Camera, recursive?: boolean, distanceFromTarget?: number, worldSpace?: boolean) => this | |||
| clone: (recursive?: boolean) => this | |||
| add: (...object: IObject3D[]) => this | |||
| remove: (...object: IObject3D[]) => this | |||
| @@ -1,4 +1,5 @@ | |||
| export {PerspectiveCamera2} from './camera/PerspectiveCamera2' | |||
| export {CameraView, type ICameraView} from './camera/CameraView' | |||
| export {ExtendedShaderMaterial} from './material/ExtendedShaderMaterial' | |||
| export {PhysicalMaterial, type PhysicalMaterialEventTypes, MeshStandardMaterial2} from './material/PhysicalMaterial' | |||
| export {ShaderMaterial2} from './material/ShaderMaterial2' | |||
| @@ -3,44 +3,40 @@ import {IUiConfigContainer, UiObjectConfig} from 'uiconfig.js' | |||
| import {ICamera} from '../ICamera' | |||
| import {Vector3} from 'three' | |||
| export function makeICameraCommonUiConfig(this: IObject3D, config: UiObjectConfig): UiObjectConfig[] { | |||
| export function makeICameraCommonUiConfig(this: ICamera, config: UiObjectConfig): UiObjectConfig[] { | |||
| return [ | |||
| { | |||
| type: 'button', | |||
| label: 'Set View', | |||
| value: ()=>{ | |||
| // todo: call setView on the camera, which will dispatch the event | |||
| (this as ICamera).dispatchEvent({type: 'setView', ui: true, camera: this as ICamera}) | |||
| config.uiRefresh?.(true, 'postFrame') | |||
| console.log('set view', this) | |||
| this.setViewToMain({ui: true}) | |||
| config.uiRefresh?.(true, 'postFrame') // config is parent config | |||
| }, | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Activate main', | |||
| hidden: ()=>(this as ICamera)?.isMainCamera, | |||
| hidden: ()=>this?.isMainCamera, | |||
| value: ()=>{ | |||
| // todo: call activateMain on the camera, which will dispatch the event | |||
| (this as ICamera).dispatchEvent({type: 'activateMain', ui: true, camera: this as ICamera}) | |||
| this.activateMain({ui: true}) | |||
| config.uiRefresh?.(true, 'postFrame') | |||
| }, | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Deactivate main', | |||
| hidden: ()=>!(this as ICamera)?.isMainCamera, | |||
| hidden: ()=>!this?.isMainCamera, | |||
| value: ()=>{ | |||
| // todo: call activateMain on the camera, which will dispatch the event | |||
| (this as ICamera).dispatchEvent({type: 'activateMain', ui: true, camera: undefined}) | |||
| this.deactivateMain({ui: true}) | |||
| config.uiRefresh?.(true, 'postFrame') | |||
| }, | |||
| }, | |||
| { | |||
| type: 'checkbox', | |||
| label: 'Auto LookAt Target', | |||
| getValue: ()=>(this as ICamera).userData.autoLookAtTarget ?? false, | |||
| getValue: ()=>this.userData.autoLookAtTarget ?? false, | |||
| setValue: (v)=>{ | |||
| (this as ICamera).userData.autoLookAtTarget = v | |||
| this.userData.autoLookAtTarget = v | |||
| config.uiRefresh?.(true, 'postFrame') | |||
| }, | |||
| }, | |||
| @@ -19,11 +19,14 @@ export const iCameraCommons = { | |||
| iObjectCommons.setDirty.call(this, {refreshScene: false, ...options}) | |||
| }, | |||
| activateMain: function(this: ICamera, options: Partial<ICameraEvent> = {}, _internal = false, _refresh = true): void { | |||
| if (!_internal) return this.dispatchEvent({ | |||
| type: 'activateMain', ...options, | |||
| camera: this, | |||
| bubbleToParent: true, | |||
| }) // this will be used by RootScene to deactivate other cameras and activate this one | |||
| if (!_internal) { | |||
| if (options.camera === null) return this.deactivateMain(options, _internal, _refresh) | |||
| return this.dispatchEvent({ | |||
| type: 'activateMain', ...options, | |||
| camera: this, | |||
| bubbleToParent: true, | |||
| }) | |||
| } // this will be used by RootScene to deactivate other cameras and activate this one | |||
| if (this.userData.__isMainCamera) return | |||
| this.userData.__isMainCamera = true | |||
| this.userData.__lastScale = this.scale.clone() | |||
| @@ -71,7 +74,7 @@ export const iCameraCommons = { | |||
| upgradeCamera: upgradeCamera, | |||
| copy: (superCopy: ICamera['copy']): ICamera['copy'] => | |||
| function(this: ICamera, camera: ICamera | Camera, recursive?, distanceFromTarget?, ...args): ICamera { | |||
| function(this: ICamera, camera: ICamera | Camera, recursive?, distanceFromTarget?, worldSpace?, ...args): ICamera { | |||
| if (!camera.isCamera) { | |||
| console.error('ICamera.copy: camera is not a Camera', camera) | |||
| return this | |||
| @@ -89,9 +92,22 @@ export const iCameraCommons = { | |||
| const minDistance = (this.controls as any)?.minDistance ?? distanceFromTarget ?? 4 | |||
| camera.getWorldDirection(this.target).multiplyScalar(minDistance).add(this.getWorldPosition(new Vector3())) | |||
| } | |||
| if (worldSpace) { // default = false | |||
| const worldPos = camera.getWorldPosition(this.position) | |||
| // this.getWorldQuaternion(this.quaternion) // todo: do if autoLookAtTarget is false | |||
| // todo up vector | |||
| if (this.parent) { | |||
| this.position.copy(this.parent.worldToLocal(worldPos)) | |||
| // this.quaternion.premultiply(this.parent.quaternion.clone().invert()) | |||
| } | |||
| } | |||
| this.updateMatrixWorld(true) | |||
| this.updateProjectionMatrix() | |||
| this.refreshAspect(true) | |||
| this.refreshAspect(false) | |||
| this.setDirty() | |||
| return this | |||
| }, | |||
| @@ -339,7 +339,6 @@ function upgradeObject3D(this: IObject3D, parent?: IObject3D|undefined, objectPr | |||
| if (!this.userData.__autoBubbleToParentEvents) this.userData.__autoBubbleToParentEvents = ['select'] | |||
| // Event bubbling. todo: set bubbleToParent in these events when dispatched from child and remove from here? | |||
| if (this.isCamera) this.userData.__autoBubbleToParentEvents.push('activateMain', 'setView') | |||
| if (this.isLight) this.assetType = 'light' | |||
| else if (this.isCamera) this.assetType = 'camera' | |||
| @@ -0,0 +1,553 @@ | |||
| import {Object3D, Vector3} from 'three' | |||
| import {Easing} from 'popmotion' | |||
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import {Box3B} from '../../three' | |||
| import {onChange, serialize, timeout} from 'ts-browser-helpers' | |||
| import {generateUiConfig, uiButton, uiDropdown, uiInput, UiObjectConfig, uiSlider, uiToggle} from 'uiconfig.js' | |||
| import {EasingFunctions, EasingFunctionType} from '../../utils' | |||
| import {CameraView, ICamera, ICameraView, PerspectiveCamera2} from '../../core' | |||
| import {AnimationResult, PopmotionPlugin} from './PopmotionPlugin' | |||
| /** | |||
| * 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<'viewChange'|'startViewChange'|'viewAdd'|'viewDelete'> { | |||
| static readonly PluginType = 'CameraViews' | |||
| enabled = true | |||
| // get dirty() { // todo: issue with recorder convergeMode? | |||
| // return this._animating | |||
| // } | |||
| constructor() { | |||
| super() | |||
| 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) | |||
| } | |||
| @serialize('cameraViews') | |||
| private _cameraViews: CameraView[] = [] | |||
| get cameraViews(): CameraView[] { | |||
| return this._cameraViews | |||
| } | |||
| get camViews(): CameraView[] { | |||
| return this._cameraViews | |||
| } | |||
| @onChange(CameraViewPlugin.prototype._animationLoop) | |||
| /** | |||
| * Loop all views indefinitely. | |||
| */ | |||
| @serialize() @uiToggle('Loop All Views') viewLooping = false | |||
| /** | |||
| * Pauses time between view changes when animating all views or looping. | |||
| */ | |||
| @serialize() @uiInput('View Pause Time') viewPauseTime = 200 | |||
| /** | |||
| * {@link EasingFunctions} | |||
| */ | |||
| @serialize() @uiDropdown('Ease', Object.keys(EasingFunctions).map((label:string)=>({label}))) animEase: EasingFunctionType = 'easeInOutSine' // ms | |||
| @serialize() @uiSlider('Duration', [10, 10000], 10) animDuration = 1000 // ms | |||
| @serialize() @uiSlider('RotationOffset', [0.2, 0.75], 0.01) rotationOffset = 0.25 | |||
| @serialize() @uiDropdown('Interpolation', ['spherical', 'linear'].map((label:string)=>({label}))) | |||
| interpolateMode: 'spherical'|'linear' = 'spherical' | |||
| private _animating = false | |||
| get animating(): boolean { | |||
| return this._animating | |||
| } | |||
| dependencies = [PopmotionPlugin] | |||
| // private _updaters: {u: ((timestamp: number) => void), time: number}[] = [] | |||
| // private _lastFrameTime = 0 // for post frame | |||
| onAdded(viewer: ThreeViewer): void { | |||
| super.onAdded(viewer) | |||
| let interactionsDisabled = false // we need this because interactionsEnabled is also set in PickingPlugin | |||
| // todo: move to PopmotionPlugin | |||
| // todo: remove event listener | |||
| viewer.addEventListener('preFrame', (_: any)=>{ | |||
| if (/* this.seekOnScroll || */ this._animating) { | |||
| if (this._viewer!.scene.mainCamera.interactionsEnabled) { | |||
| this._viewer!.scene.mainCamera.interactionsEnabled = false | |||
| interactionsDisabled = true | |||
| // console.log(interactionsDisabled) | |||
| } | |||
| } else if (interactionsDisabled) { | |||
| this._viewer!.scene.mainCamera.interactionsEnabled = true | |||
| interactionsDisabled = false | |||
| // console.log(interactionsDisabled) | |||
| } | |||
| // 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: ThreeViewer): void { | |||
| // viewer.removeEventListener('postFrame', this._postFrame) | |||
| // window.removeEventListener('wheel', this._wheel) | |||
| // window.removeEventListener('pointermove', this._pointerMove) | |||
| return super.onRemove(viewer) | |||
| } | |||
| @uiButton('Reset To First View') | |||
| public async resetToFirstView(duration = 100) { | |||
| if (!this.enabled) return | |||
| this._currentView = undefined | |||
| await this.animateToView(0, duration) | |||
| await timeout(2) | |||
| } | |||
| @uiButton('Add Current View') | |||
| async addCurrentView() { | |||
| if (!this.enabled) 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: CameraView) { | |||
| this._cameraViews.push(view) | |||
| view.addEventListener('setView', this._viewSetView) | |||
| view.addEventListener('animateView', this._viewAnimateView) | |||
| this.uiConfig.uiRefresh?.() | |||
| this.dispatchEvent({type: 'viewAdd', view}) | |||
| } | |||
| protected _viewSetView = ({view, camera}: {view: CameraView, camera?: ICamera}&any) => { | |||
| if (!view) { | |||
| this._viewer?.console.warn('Invalid view', view) | |||
| return | |||
| } | |||
| this.setView(view, camera) | |||
| } | |||
| protected _viewAnimateView = async({view, camera, duration, easing, throwOnStop}: {view: CameraView, camera?: ICamera, duration?: number, easing?: Easing|EasingFunctionType, throwOnStop?: boolean}&any) => { | |||
| if (!view) { | |||
| this._viewer?.console.warn('Invalid view', view) | |||
| return | |||
| } | |||
| return this.animateToView(view, duration || this.animDuration, easing || this.animEase, camera, throwOnStop) | |||
| } | |||
| deleteView(view: CameraView) { | |||
| const i = this._cameraViews.indexOf(view) | |||
| if (i >= 0) | |||
| this._cameraViews.splice(i, 1) | |||
| this.uiConfig.uiRefresh?.() | |||
| this.dispatchEvent({type: 'viewDelete', view}) | |||
| } | |||
| getView(camera?: ICamera, worldSpace = true) { | |||
| camera = camera || this._viewer?.scene.mainCamera | |||
| if (!camera) return new CameraView() | |||
| return camera.getView(worldSpace) | |||
| } | |||
| setView(view: ICameraView, camera?: ICamera) { | |||
| camera = camera || this._viewer?.scene.mainCamera | |||
| if (!camera) return | |||
| camera.setView(view) | |||
| } | |||
| private _currentView: CameraView | undefined | |||
| @uiButton('Focus Next') 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) | |||
| } | |||
| @uiButton('Focus Previous') 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) | |||
| } | |||
| private _popAnimations: AnimationResult[] = [] | |||
| async animateToView(_view: CameraView|number, duration?: number, easing?: Easing|EasingFunctionType, camera?: ICamera, 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] : _view | |||
| this._currentView = view | |||
| this._animating = true | |||
| 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: any = (typeof easing === 'function' ? easing : EasingFunctions[easing || this.animEase]) as (x: number) => number | |||
| // 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._animating = false | |||
| this.dispatchEvent({type: 'viewChange', view}) | |||
| await timeout(10) | |||
| } | |||
| @uiButton('Animate All Views') | |||
| async animateAllViews() { | |||
| if (!this.enabled) 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 | |||
| } | |||
| @uiButton('Stop All Animations') | |||
| async stopAllAnimations() { | |||
| this.viewLooping = false | |||
| this._popAnimations.forEach(a => a?.stop?.()) | |||
| this._popAnimations = [] | |||
| while (this._animating || this._animationLooping) { | |||
| await timeout(100) | |||
| } | |||
| } | |||
| fromJSON(data: any, meta?: any): this | null { | |||
| this._cameraViews.forEach(v=>this.deleteView(v)) // deserialize pushes to the existing array | |||
| if (super.fromJSON(data, meta)) { | |||
| this.uiConfig.uiRefresh?.() | |||
| return this | |||
| } | |||
| return null | |||
| } | |||
| public async animateToObject(selected?: Object3D, distanceMultiplier = 4, duration?: number, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 5.0}) { | |||
| if (!this._viewer) return | |||
| const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot.modelObject, 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) | |||
| } | |||
| public async animateToFitObject(selected?: Object3D, distanceMultiplier = 1.5, duration = 1000, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 50.0}) { | |||
| if (!this._viewer) return | |||
| const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot, false, true) | |||
| const center = bbox.getCenter(new Vector3()) // world position | |||
| const size = bbox.getSize(new Vector3()) | |||
| const cam = this._viewer.scene.mainCamera | |||
| let cameraZ = 1 | |||
| if (cam.isPerspectiveCamera) { | |||
| // get the max side of the bounding box (fits to width OR height as needed ) | |||
| const fov = (cam as PerspectiveCamera2).fov * (Math.PI / 180) | |||
| const fovh = 2 * Math.atan(Math.tan(fov / 2) * cam.aspect) | |||
| const dx = size.z / 2 + Math.abs(size.x / 2 / Math.tan(fovh / 2)) | |||
| const dy = size.z / 2 + Math.abs(size.y / 2 / Math.tan(fov / 2)) | |||
| cameraZ = Math.max(dx, dy) | |||
| } | |||
| 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 | |||
| */ | |||
| public async animateToTarget(distanceFromTarget: number, center: Vector3, duration?: number, ease?: Easing|EasingFunctionType) { | |||
| 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) | |||
| } | |||
| uiConfig: UiObjectConfig = { | |||
| type: 'folder', | |||
| label: 'Camera Views', | |||
| // expanded: true, | |||
| children: [ | |||
| ()=>[...this._cameraViews.map(view => view.uiConfig)], | |||
| ...generateUiConfig(this), | |||
| ], | |||
| } | |||
| get animationLooping(): boolean { | |||
| return this._animationLooping | |||
| } | |||
| private _viewQueue: CameraView[] = [] | |||
| private _animationLooping = false | |||
| private _infiniteLooping = true | |||
| private async _animationLoop() { | |||
| if (this._animationLooping) return | |||
| this._animationLooping = true | |||
| while (this.viewLooping || !this._infiniteLooping) { | |||
| if (!this.enabled) 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?: ICamera, worldSpace = true) { | |||
| return this.getView(camera, worldSpace) | |||
| } | |||
| /** | |||
| * @deprecated - renamed to {@link setView} or {@link ICamera.setView} | |||
| * @param view | |||
| */ | |||
| setCurrentCameraView(view: CameraView) { | |||
| return this.setView(view) | |||
| } | |||
| /** | |||
| * @deprecated - use {@link animateToView} instead | |||
| * @param view | |||
| */ | |||
| async focusView(view: CameraView) { | |||
| return this.animateToView(view) | |||
| } | |||
| // endregion | |||
| // region to be ported to other plugins | |||
| // /** | |||
| // * For slight rotation of camera when seekOnScroll is enabled | |||
| // */ | |||
| // private _pointerMove(ev: PointerEvent) { | |||
| // if (!this.enabled) return | |||
| // if (!this._animating && this.seekOnScroll) { | |||
| // const cam = this._viewer?.scene.mainCamera | |||
| // if (!cam) return | |||
| // const s = new Spherical() | |||
| // const p = cam.position | |||
| // const t = cam.target | |||
| // const q = new Quaternion().setFromUnitVectors(cam.cameraObject.up, new Vector3(0, 1, 0)) | |||
| // const qi = q.clone().invert() | |||
| // const offset = p.clone().sub(t) | |||
| // offset.applyQuaternion(q) | |||
| // s.setFromVector3(offset) | |||
| // s.theta += this.rotationOffset * ev.movementX / this._viewer!.canvas!.clientWidth | |||
| // s.phi += this.rotationOffset * ev.movementY / this._viewer!.canvas!.clientHeight | |||
| // s.makeSafe() | |||
| // offset.setFromSpherical(s) | |||
| // offset.applyQuaternion(qi) | |||
| // p.copy(t).add(offset) | |||
| // cam.setDirty() | |||
| // } | |||
| // } | |||
| // // @uiToggle() @serialize() | |||
| // animateOnScroll = false // buggy | |||
| // | |||
| // @uiToggle() @serialize() | |||
| // seekOnScroll = false | |||
| // private _scrollAnimationState = 0 | |||
| // scrollAnimationDamping = 0.1 | |||
| // private _wheel(ev: any | WheelEvent) { | |||
| // if (!this.enabled) return | |||
| // if (this.seekOnScroll && !this._animating) { | |||
| // // if (ev.deltaY > 0) this.focusNext(false) | |||
| // // else this.focusPrevious(false) | |||
| // } else if (Math.abs(ev.deltaY) > 0.001) { | |||
| // this._scrollAnimationState = -1. * Math.sign(ev.deltaY) | |||
| // } | |||
| // } | |||
| // private _driver: Driver = (update)=>{ | |||
| // return { | |||
| // start: ()=>this._updaters.push({u:update, time:0}), | |||
| // stop: ()=> this._updaters.splice(this._updaters.findIndex(u=>u.u === update), 1), | |||
| // } | |||
| // } | |||
| // private _fadeDisabled = false | |||
| // todo: same code used in PopmotionPlugin, merge somehow | |||
| // private _postFrame() { | |||
| // if (!this._viewer) return | |||
| // if (!this.enabled || !this._animating) { | |||
| // this._lastFrameTime = 0 | |||
| // if (this._fadeDisabled) { | |||
| // this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')?.enable(CameraViewPlugin.PluginType) | |||
| // this._fadeDisabled = false | |||
| // } | |||
| // // console.log('not anim') | |||
| // return | |||
| // } | |||
| // const time = now() / 1000.0 | |||
| // if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 60.0 | |||
| // let delta = time - this._lastFrameTime | |||
| // this._lastFrameTime = time | |||
| // delta = delta * (this.animateOnScroll ? this._scrollAnimationState : 1) | |||
| // | |||
| // const d = this._viewer.getPluginByType<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta() | |||
| // if (d && d > 0) delta = d | |||
| // if (d === 0) return // not converged yet. | |||
| // // if d < 0: not recording, do nothing | |||
| // | |||
| // delta *= 1000 | |||
| // | |||
| // // delta = 16.666 | |||
| // | |||
| // // console.log(delta) | |||
| // // console.log(dt) | |||
| // // | |||
| // | |||
| // if (delta <= 0) return | |||
| // | |||
| // this._updaters.forEach(u=>{ | |||
| // let dt = delta | |||
| // if (u.time + dt < 0) dt = -u.time | |||
| // u.time += dt | |||
| // if (Math.abs(dt) > 0.001) | |||
| // u.u(dt) | |||
| // }) | |||
| // if (this._scrollAnimationState < 0.001) this._scrollAnimationState = 0 | |||
| // else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping | |||
| // | |||
| // if (!this._fadeDisabled) { | |||
| // const ff = this._viewer.getPluginByType<FrameFadePlugin>('FrameFade') | |||
| // if (ff) { | |||
| // ff.disable(CameraViewPlugin.PluginType) | |||
| // this._fadeDisabled = true | |||
| // } | |||
| // } | |||
| // } | |||
| // @uiButton('Record All Views') | |||
| // public async recordAllViews(onStart?: ()=>void, downloadOnEnd = true) { | |||
| // if (!this.enabled) return | |||
| // const recorder = this._viewer?.getPluginByType<CanvasRecorderPlugin>('CanvasRecorder') | |||
| // if (!recorder || !recorder.enabled) return | |||
| // if (this._cameraViews.length < 1) return | |||
| // await this.resetToFirstView() | |||
| // if (recorder.isRecording()) { | |||
| // console.error('CanvasRecorderPlugin is already recording') | |||
| // return | |||
| // } | |||
| // return new Promise<Blob|undefined>((resolve, reject) => { | |||
| // const listener2 = ()=>{ | |||
| // recorder.removeEventListener('start', listenerStart) | |||
| // recorder.removeEventListener('stop', listener2) | |||
| // recorder.removeEventListener('error', listenerError) | |||
| // } | |||
| // const listenerStart = async() => { | |||
| // listener2() | |||
| // onStart?.() | |||
| // await this.animateAllViews() | |||
| // const blob = await recorder.stopRecording() | |||
| // if (downloadOnEnd) { | |||
| // const name = await this._viewer?.prompt('Canvas Recorder: Save file as', 'recording.mp4') | |||
| // if (name !== null && blob) await this._downloadBlob(blob, name || 'recording.mp4') | |||
| // } | |||
| // resolve(blob) | |||
| // } | |||
| // const listenerError = async() => { | |||
| // listener2() | |||
| // reject() | |||
| // } | |||
| // recorder.addEventListener('start', listenerStart) | |||
| // recorder.addEventListener('stop', listener2) | |||
| // recorder.addEventListener('error', listenerError) | |||
| // if (!recorder.startRecording()) { | |||
| // console.error('cannot start recording') | |||
| // return | |||
| // } | |||
| // }) | |||
| // } | |||
| // private async _downloadBlob(blob: Blob, name: string) { | |||
| // const tr = this._viewer?.getPluginByType<FileTransferPlugin>('FileTransferPlugin') | |||
| // if (!tr) { | |||
| // this._viewer?.console.error('FileTransferPlugin required to export/download file') | |||
| // return | |||
| // } | |||
| // await tr.exportFile(blob, name) | |||
| // } | |||
| // endregion | |||
| } | |||
| @@ -5,7 +5,8 @@ import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import type {FrameFadePlugin} from '../pipeline/FrameFadePlugin' | |||
| import type {ProgressivePlugin} from '../pipeline/ProgressivePlugin' | |||
| import {generateUUID} from '../../three' | |||
| import {makeSetterFor} from '../../utils' | |||
| import {animateCameraToViewLinear, animateCameraToViewSpherical, EasingFunctions, makeSetterFor} from '../../utils' | |||
| import {ICamera, ICameraView} from '../../core' | |||
| export interface AnimationResult{ | |||
| id: string | |||
| @@ -47,6 +48,7 @@ export class PopmotionPlugin extends AViewerPluginSync<''> { | |||
| dependencies = [] | |||
| private _fadeDisabled = false | |||
| /** | |||
| * Disable the frame fade plugin while animation is running | |||
| */ | |||
| @@ -196,13 +198,31 @@ export class PopmotionPlugin extends AViewerPluginSync<''> { | |||
| return this.animations[uuid] | |||
| } | |||
| async animateAsync<V>(options: AnimationOptions<V>& {target?: any, key?: string}): Promise<string> { | |||
| return this.animate(options).promise | |||
| async animateAsync<V>(options: AnimationOptions<V>& {target?: any, key?: string}, animations?: AnimationResult[]): Promise<string> { | |||
| const anim = this.animate(options) | |||
| if (animations) animations.push(anim) | |||
| return anim.promise | |||
| } | |||
| async animateTargetAsync<T>(target: T, key: keyof T, options: AnimationOptions<T[keyof T]>): Promise<string> { | |||
| return this.animate({...options, target, key: key as string}).promise | |||
| async animateTargetAsync<T>(target: T, key: keyof T, options: AnimationOptions<T[keyof T]>, animations?: AnimationResult[]): Promise<string> { | |||
| const anim = this.animate({...options, target, key: key as string}) | |||
| if (animations) animations.push(anim) | |||
| return anim.promise | |||
| } | |||
| // todo : animateObject/animateTarget | |||
| animateCamera(camera: ICamera, view: ICameraView, spherical = true, options?: Partial<AnimationOptions<any>>) { | |||
| const anim = spherical ? | |||
| animateCameraToViewSpherical(camera, view) : | |||
| animateCameraToViewLinear(camera, view) | |||
| return this.animate({ | |||
| ease: EasingFunctions.linear, | |||
| duration: 1000, | |||
| ...anim, ...options, | |||
| }) | |||
| } | |||
| async animateCameraAsync(camera: ICamera, view: ICameraView, spherical = true, options?: Partial<AnimationOptions<any>>, animations?: AnimationResult[]) { | |||
| const anim = this.animateCamera(camera, view, spherical, options) | |||
| if (animations) animations.push(anim) | |||
| return anim.promise | |||
| } | |||
| } | |||
| @@ -36,3 +36,4 @@ export {TonemapPlugin} from './postprocessing/TonemapPlugin' | |||
| // animation | |||
| export {GLTFAnimationPlugin} from './animation/GLTFAnimationPlugin' | |||
| export {PopmotionPlugin} from './animation/PopmotionPlugin' | |||
| export {CameraViewPlugin} from './animation/CameraViewPlugin' | |||
| @@ -3,7 +3,7 @@ export {overrideThreeCache} from './cache' | |||
| export {dataTextureFromColor, dataTextureFromVec4, halfFloatToRgbe} from './conversion' | |||
| export {uniform, matDefine} from './decorators' | |||
| export {getEncodingComponents, getTexelEncoding, getTexelDecoding, getTexelDecoding2, getTexelDecodingFunction, getTexelEncodingFunction, getTextureColorSpaceFromMap} from './encoding' | |||
| export {generateUUID, toIndexedGeometry, isInScene} from './misc' | |||
| export {generateUUID, toIndexedGeometry, isInScene, localToWorldQuaternion, worldToLocalQuaternion} from './misc' | |||
| export {getTextureDataType, textureToCanvas, textureDataToImageData, textureToDataUrl, texImageToCanvas} from './texture' | |||
| export {threeConstMappings} from './const-mappings' | |||
| export {ObjectPicker} from './ObjectPicker' | |||
| @@ -1,4 +1,4 @@ | |||
| import {BufferGeometry, MathUtils} from 'three' | |||
| import {BufferGeometry, MathUtils, Object3D, Quaternion} from 'three' | |||
| import {mergeVertices} from 'three/examples/jsm/utils/BufferGeometryUtils.js' | |||
| import {IGeometry, IMaterial, IObject3D, IScene, ITexture} from '../../core' | |||
| @@ -33,3 +33,25 @@ export function isInScene(...sceneObj: (IGeometry|IMaterial|IObject3D|ITexture)[ | |||
| } | |||
| return false | |||
| } | |||
| /** | |||
| * Convert a world-space quaternion to local-space quaternion. | |||
| * https://github.com/mrdoob/three.js/pull/20243 | |||
| * @param object | |||
| * @param quaternion | |||
| * @param _q | |||
| */ | |||
| export function worldToLocalQuaternion(object: Object3D, quaternion: Quaternion, _q = new Quaternion()) { | |||
| return quaternion.premultiply(object.getWorldQuaternion(_q).invert()) | |||
| } | |||
| /** | |||
| * Convert a local-space quaternion to world-space quaternion. | |||
| * https://github.com/mrdoob/three.js/pull/20243 | |||
| * @param object | |||
| * @param quaternion | |||
| * @param _q | |||
| */ | |||
| export function localToWorldQuaternion(object: Object3D, quaternion: Quaternion, _q = new Quaternion()) { | |||
| return quaternion.premultiply(object.getWorldQuaternion(_q)) | |||
| } | |||
| @@ -19,6 +19,7 @@ import { | |||
| linear, | |||
| } from 'popmotion' | |||
| import {timeout} from 'ts-browser-helpers' | |||
| import {MathUtils} from 'three' | |||
| export {animate} | |||
| export type {AnimationOptions, KeyframeOptions, Easing} | |||
| @@ -124,3 +125,15 @@ export async function animateAsync<V=number>(options: AnimationOptions<V>, anima | |||
| }) | |||
| } | |||
| export function lerpAngle(a: number, b: number, t: number) { | |||
| const d = b - a | |||
| if (d >= Math.PI) { | |||
| return a + (d - Math.PI * 2) * t | |||
| } else if (d <= -Math.PI) { | |||
| return a + (d + Math.PI * 2) * t | |||
| } else { | |||
| return a + d * t | |||
| } | |||
| } | |||
| export const lerp = MathUtils.lerp | |||
| @@ -0,0 +1,120 @@ | |||
| import {Quaternion, Spherical, Vector3} from 'three' | |||
| import {worldToLocalQuaternion} from '../three' | |||
| import {CameraView, ICamera, ICameraView} from '../core' | |||
| import {AnimationOptions} from 'popmotion' | |||
| import {lerp, lerpAngle} from './animation' | |||
| export function sphericalFromCameraView(view: Pick<CameraView, 'position'|'target'>): Spherical { | |||
| const pos = view.position.clone() | |||
| pos.sub(view.target) | |||
| const spherical = new Spherical().setFromVector3(pos) | |||
| spherical.makeSafe() // todo: is it needed? | |||
| return spherical | |||
| } | |||
| export function animateCameraToViewSpherical(camera: ICamera, view: ICameraView): AnimationOptions<number> { | |||
| // similar to orbit controls | |||
| const parent = camera.parent | |||
| const target = camera.target.clone() | |||
| const position = camera.getWorldPosition(new Vector3()) | |||
| const init = { | |||
| position, target, zoom: camera.zoom, | |||
| spherical: sphericalFromCameraView({position, target}), | |||
| } | |||
| const current = { | |||
| position: new Vector3(), | |||
| target: new Vector3(), | |||
| zoom: 1, | |||
| spherical: new Spherical(), | |||
| } | |||
| const final = { | |||
| position: view.position, | |||
| target: view.target, | |||
| zoom: view.zoom, | |||
| spherical: sphericalFromCameraView(view), | |||
| } | |||
| function setter() { | |||
| camera.position.copy(parent ? parent.worldToLocal(current.position) : current.position) | |||
| camera.target.copy(current.target) // always in world space | |||
| camera.zoom = current.zoom | |||
| // lookAt in setDirty updates the quaternion | |||
| camera.setDirty() // because it has min change distance in setter | |||
| } | |||
| return { | |||
| from: 0, | |||
| to: 1, | |||
| onUpdate: (v) => { | |||
| current.spherical.phi = lerpAngle(init.spherical.phi, final.spherical.phi, v) | |||
| current.spherical.theta = lerpAngle(init.spherical.theta, final.spherical.theta, v) | |||
| current.spherical.radius = lerp(init.spherical.radius, final.spherical.radius, v) | |||
| current.target.copy(init.target).lerp(final.target, v) | |||
| current.position.setFromSpherical(current.spherical) | |||
| current.position.add(current.target) | |||
| current.zoom = lerp(init.zoom, final.zoom, v) | |||
| setter() | |||
| }, | |||
| onComplete: () => { | |||
| current.position.copy(final.position) | |||
| current.target.copy(final.target) | |||
| current.zoom = final.zoom | |||
| setter() | |||
| }, | |||
| onStop: () => { | |||
| throw new Error('Animation Stopped') | |||
| }, | |||
| } | |||
| } | |||
| export function animateCameraToViewLinear(camera: ICamera, view: ICameraView): AnimationOptions<number> { | |||
| // similar to orbit controls | |||
| // so camera.up is the orbit axis | |||
| const parent = camera.parent | |||
| const target = camera.target.clone() | |||
| const position = camera.getWorldPosition(new Vector3()) | |||
| const quaternion = camera.getWorldQuaternion(new Quaternion()) | |||
| const init = { | |||
| position, target, quaternion, zoom: camera.zoom, | |||
| } | |||
| const current = { | |||
| position: new Vector3(), | |||
| target: new Vector3(), | |||
| quaternion: new Quaternion(), | |||
| zoom: 1, | |||
| } | |||
| const final = view | |||
| function setter() { | |||
| camera.position.copy(parent ? parent.worldToLocal(current.position) : current.position) | |||
| camera.target.copy(current.target) // always in world space | |||
| camera.quaternion.copy(parent ? worldToLocalQuaternion(parent, current.quaternion, camera.quaternion) : current.quaternion) | |||
| camera.zoom = current.zoom | |||
| camera.setDirty() // because it has min change distance in setter | |||
| } | |||
| return { | |||
| from: 0, | |||
| to: 1, | |||
| onUpdate: (v) => { | |||
| current.position.lerpVectors(init.position, final.position, v) | |||
| current.target.lerpVectors(init.target, final.target, v) | |||
| current.quaternion.slerpQuaternions(init.quaternion, final.quaternion, v) | |||
| current.zoom = lerp(init.zoom, final.zoom, v) | |||
| setter() | |||
| }, | |||
| onComplete: () => { | |||
| current.position.copy(final.position) | |||
| current.target.copy(final.target) | |||
| current.quaternion.copy(final.quaternion) | |||
| current.zoom = final.zoom | |||
| setter() | |||
| }, | |||
| onStop: () => { | |||
| throw new Error('Animation Stopped') | |||
| }, | |||
| } | |||
| } | |||
| @@ -6,6 +6,7 @@ export {Dropzone, type DropFile, type ListenerCallback, type DropEventType} from | |||
| export {ThreeSerialization, type SerializationMetaType, type SerializationResourcesType, MetaImporter, metaToResources, getEmptyMeta, metaFromResources, convertArrayBufferToStringsInMeta, convertStringsToArrayBuffersInMeta, copyMaterialUserData, copyObject3DUserData, copyUserData, copyTextureUserData, jsonToBlob, serializeTextureInExtras} from './serialization' | |||
| export {shaderReplaceString} from './shader-helpers' | |||
| export {makeGLBFile} from './gltf' | |||
| export {animateAsync, animateTarget, EasingFunctions, makeSetterFor, animate} from './animation' | |||
| export {animateCameraToViewLinear, animateCameraToViewSpherical, sphericalFromCameraView} from './camera-anim' | |||
| export {animateAsync, animateTarget, EasingFunctions, makeSetterFor, animate, lerp, lerpAngle} from './animation' | |||
| export type {Easing, KeyframeOptions, AnimationOptions, EasingFunctionType, AnimateResult} from './animation' | |||
| @@ -52,6 +52,7 @@ import {IViewerPlugin, IViewerPluginSync} from './IViewerPlugin' | |||
| import {DropzonePlugin, DropzonePluginOptions} from '../plugins/interaction/DropzonePlugin' | |||
| import {uiConfig, uiFolderContainer, UiObjectConfig} from 'uiconfig.js' | |||
| import {IRenderTarget} from '../rendering' | |||
| import type {ProgressivePlugin} from '../plugins' | |||
| import {TonemapPlugin} from '../plugins' | |||
| import {VERSION} from './version' | |||
| @@ -299,11 +300,10 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| this.addEventListener('postFrame', () => { // todo: move inside RootScene. | |||
| const cam = this._scene.mainCamera | |||
| if (cam && cam.canUserInteract) { | |||
| // todo | |||
| // const d = this.getPluginByType<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta() | |||
| // // if (d && d > 0) delta = d | |||
| // if (d !== undefined && d === 0) return // not converged yet. | |||
| // // if d < 0 or undefined: not recording, do nothing | |||
| const d = this.getPlugin<ProgressivePlugin>('ProgressivePlugin')?.postFrameConvergedRecordingDelta() | |||
| // if (d && d > 0) delta = d | |||
| if (d !== undefined && d === 0) return // not converged yet. | |||
| // if d < 0 or undefined: not recording, do nothing | |||
| cam.controls?.update() | |||
| } | |||
| @@ -1019,14 +1019,8 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| this.console.warn('Cannot find camera', event) | |||
| return | |||
| } | |||
| this._scene.mainCamera.copy(event.camera) | |||
| const worldPos = event.camera.getWorldPosition(this._scene.mainCamera.position) | |||
| // camera.getWorldQuaternion(this.quaternion) // todo: do if autoLookAtTarget is false | |||
| if (this._scene.mainCamera.parent) { | |||
| this._scene.mainCamera.position.copy(this._scene.mainCamera.parent.worldToLocal(worldPos)) | |||
| // this.quaternion.premultiply(this.parent.quaternion.clone().invert()) | |||
| } | |||
| this._scene.mainCamera.setDirty() | |||
| const camera = this._scene.mainCamera | |||
| camera.setViewFromCamera(event.camera) // default is worldSpace | |||
| } else if (event.type === 'activateMain') | |||
| this._scene.mainCamera = event.camera || undefined // event.camera should have been upgraded when added to the scene. | |||
| } | |||